rails_console_ai 0.13.0
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/CHANGELOG.md +95 -0
- data/LICENSE +21 -0
- data/README.md +328 -0
- data/app/controllers/rails_console_ai/application_controller.rb +28 -0
- data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
- data/app/models/rails_console_ai/session.rb +23 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
- data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
- data/config/routes.rb +4 -0
- data/lib/generators/rails_console_ai/install_generator.rb +26 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
- data/lib/rails_console_ai/channel/base.rb +23 -0
- data/lib/rails_console_ai/channel/console.rb +457 -0
- data/lib/rails_console_ai/channel/slack.rb +182 -0
- data/lib/rails_console_ai/configuration.rb +185 -0
- data/lib/rails_console_ai/console_methods.rb +277 -0
- data/lib/rails_console_ai/context_builder.rb +120 -0
- data/lib/rails_console_ai/conversation_engine.rb +1142 -0
- data/lib/rails_console_ai/engine.rb +5 -0
- data/lib/rails_console_ai/executor.rb +461 -0
- data/lib/rails_console_ai/providers/anthropic.rb +122 -0
- data/lib/rails_console_ai/providers/base.rb +118 -0
- data/lib/rails_console_ai/providers/bedrock.rb +171 -0
- data/lib/rails_console_ai/providers/local.rb +112 -0
- data/lib/rails_console_ai/providers/openai.rb +114 -0
- data/lib/rails_console_ai/railtie.rb +34 -0
- data/lib/rails_console_ai/repl.rb +65 -0
- data/lib/rails_console_ai/safety_guards.rb +207 -0
- data/lib/rails_console_ai/session_logger.rb +90 -0
- data/lib/rails_console_ai/slack_bot.rb +473 -0
- data/lib/rails_console_ai/storage/base.rb +27 -0
- data/lib/rails_console_ai/storage/file_storage.rb +63 -0
- data/lib/rails_console_ai/tools/code_tools.rb +126 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
- data/lib/rails_console_ai/tools/model_tools.rb +95 -0
- data/lib/rails_console_ai/tools/registry.rb +478 -0
- data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
- data/lib/rails_console_ai/version.rb +3 -0
- data/lib/rails_console_ai.rb +214 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +152 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module SessionLogger
|
|
3
|
+
class << self
|
|
4
|
+
def log(attrs)
|
|
5
|
+
return unless RailsConsoleAI.configuration.session_logging
|
|
6
|
+
return unless table_exists?
|
|
7
|
+
|
|
8
|
+
create_attrs = {
|
|
9
|
+
query: attrs[:query],
|
|
10
|
+
conversation: Array(attrs[:conversation]).to_json,
|
|
11
|
+
input_tokens: attrs[:input_tokens] || 0,
|
|
12
|
+
output_tokens: attrs[:output_tokens] || 0,
|
|
13
|
+
user_name: attrs[:user_name] || current_user_name,
|
|
14
|
+
mode: attrs[:mode].to_s,
|
|
15
|
+
name: attrs[:name],
|
|
16
|
+
code_executed: attrs[:code_executed],
|
|
17
|
+
code_output: attrs[:code_output],
|
|
18
|
+
code_result: attrs[:code_result],
|
|
19
|
+
console_output: attrs[:console_output],
|
|
20
|
+
executed: attrs[:executed] || false,
|
|
21
|
+
provider: RailsConsoleAI.configuration.provider.to_s,
|
|
22
|
+
model: RailsConsoleAI.configuration.resolved_model,
|
|
23
|
+
duration_ms: attrs[:duration_ms],
|
|
24
|
+
created_at: Time.respond_to?(:current) ? Time.current : Time.now
|
|
25
|
+
}
|
|
26
|
+
create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
|
|
27
|
+
record = session_class.create!(create_attrs)
|
|
28
|
+
record.id
|
|
29
|
+
rescue => e
|
|
30
|
+
msg = "RailsConsoleAI: session logging failed: #{e.class}: #{e.message}"
|
|
31
|
+
$stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
|
|
32
|
+
RailsConsoleAI.logger.warn(msg)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find_by_slack_thread(thread_ts)
|
|
37
|
+
return nil unless RailsConsoleAI.configuration.session_logging
|
|
38
|
+
return nil unless table_exists?
|
|
39
|
+
session_class.where(slack_thread_ts: thread_ts).order(created_at: :desc).first
|
|
40
|
+
rescue => e
|
|
41
|
+
RailsConsoleAI.logger.warn("RailsConsoleAI: session lookup failed: #{e.class}: #{e.message}")
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update(id, attrs)
|
|
46
|
+
return unless id
|
|
47
|
+
return unless RailsConsoleAI.configuration.session_logging
|
|
48
|
+
return unless table_exists?
|
|
49
|
+
|
|
50
|
+
updates = {}
|
|
51
|
+
updates[:conversation] = Array(attrs[:conversation]).to_json if attrs.key?(:conversation)
|
|
52
|
+
updates[:input_tokens] = attrs[:input_tokens] if attrs.key?(:input_tokens)
|
|
53
|
+
updates[:output_tokens] = attrs[:output_tokens] if attrs.key?(:output_tokens)
|
|
54
|
+
updates[:code_executed] = attrs[:code_executed] if attrs.key?(:code_executed)
|
|
55
|
+
updates[:code_output] = attrs[:code_output] if attrs.key?(:code_output)
|
|
56
|
+
updates[:code_result] = attrs[:code_result] if attrs.key?(:code_result)
|
|
57
|
+
updates[:console_output] = attrs[:console_output] if attrs.key?(:console_output)
|
|
58
|
+
updates[:executed] = attrs[:executed] if attrs.key?(:executed)
|
|
59
|
+
updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
|
|
60
|
+
updates[:name] = attrs[:name] if attrs.key?(:name)
|
|
61
|
+
|
|
62
|
+
session_class.where(id: id).update_all(updates) unless updates.empty?
|
|
63
|
+
rescue => e
|
|
64
|
+
msg = "RailsConsoleAI: session update failed: #{e.class}: #{e.message}"
|
|
65
|
+
$stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
|
|
66
|
+
RailsConsoleAI.logger.warn(msg)
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def table_exists?
|
|
73
|
+
# Only cache positive results — retry on failure so transient
|
|
74
|
+
# errors (boot timing, connection not ready) don't stick forever
|
|
75
|
+
return true if @table_exists
|
|
76
|
+
@table_exists = session_class.connection.table_exists?('rails_console_ai_sessions')
|
|
77
|
+
rescue
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def session_class
|
|
82
|
+
Object.const_get('RailsConsoleAI::Session')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def current_user_name
|
|
86
|
+
RailsConsoleAI.current_user || ENV['USER']
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'rails_console_ai/channel/slack'
|
|
6
|
+
require 'rails_console_ai/conversation_engine'
|
|
7
|
+
require 'rails_console_ai/context_builder'
|
|
8
|
+
require 'rails_console_ai/providers/base'
|
|
9
|
+
require 'rails_console_ai/executor'
|
|
10
|
+
|
|
11
|
+
module RailsConsoleAI
|
|
12
|
+
class SlackBot
|
|
13
|
+
def initialize
|
|
14
|
+
@bot_token = RailsConsoleAI.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
|
|
15
|
+
@app_token = RailsConsoleAI.configuration.slack_app_token || ENV['SLACK_APP_TOKEN']
|
|
16
|
+
@channel_ids = resolve_channel_ids
|
|
17
|
+
|
|
18
|
+
raise ConfigurationError, "SLACK_BOT_TOKEN is required" unless @bot_token
|
|
19
|
+
raise ConfigurationError, "SLACK_APP_TOKEN is required (Socket Mode)" unless @app_token
|
|
20
|
+
raise ConfigurationError, "slack_allowed_usernames must be configured (e.g. ['alice'] or 'ALL')" unless RailsConsoleAI.configuration.slack_allowed_usernames
|
|
21
|
+
|
|
22
|
+
@bot_user_id = nil
|
|
23
|
+
@sessions = {} # thread_ts → { channel:, engine:, thread: }
|
|
24
|
+
@user_cache = {} # slack user_id → display_name
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
|
|
30
|
+
log_startup
|
|
31
|
+
|
|
32
|
+
loop do
|
|
33
|
+
run_socket_mode
|
|
34
|
+
puts "Reconnecting in 5s..."
|
|
35
|
+
sleep 5
|
|
36
|
+
end
|
|
37
|
+
rescue Interrupt
|
|
38
|
+
puts "\nSlackBot shutting down."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# --- Socket Mode connection ---
|
|
44
|
+
|
|
45
|
+
def run_socket_mode
|
|
46
|
+
url = obtain_wss_url
|
|
47
|
+
uri = URI.parse(url)
|
|
48
|
+
|
|
49
|
+
tcp = TCPSocket.new(uri.host, uri.port || 443)
|
|
50
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
|
|
51
|
+
ssl.hostname = uri.host
|
|
52
|
+
ssl.connect
|
|
53
|
+
|
|
54
|
+
# WebSocket handshake
|
|
55
|
+
path = "#{uri.path}?#{uri.query}"
|
|
56
|
+
handshake = [
|
|
57
|
+
"GET #{path} HTTP/1.1",
|
|
58
|
+
"Host: #{uri.host}",
|
|
59
|
+
"Upgrade: websocket",
|
|
60
|
+
"Connection: Upgrade",
|
|
61
|
+
"Sec-WebSocket-Key: #{SecureRandom.base64(16)}",
|
|
62
|
+
"Sec-WebSocket-Version: 13",
|
|
63
|
+
"", ""
|
|
64
|
+
].join("\r\n")
|
|
65
|
+
|
|
66
|
+
ssl.write(handshake)
|
|
67
|
+
|
|
68
|
+
# Read HTTP 101 response headers
|
|
69
|
+
response_line = ssl.gets
|
|
70
|
+
unless response_line&.include?("101")
|
|
71
|
+
raise "WebSocket handshake failed: #{response_line}"
|
|
72
|
+
end
|
|
73
|
+
# Consume remaining headers
|
|
74
|
+
loop do
|
|
75
|
+
line = ssl.gets
|
|
76
|
+
break if line.nil? || line.strip.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
puts "Connected to Slack Socket Mode."
|
|
80
|
+
|
|
81
|
+
# Main read loop
|
|
82
|
+
loop do
|
|
83
|
+
data = read_ws_frame(ssl)
|
|
84
|
+
next unless data
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
msg = JSON.parse(data, symbolize_names: true)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Acknowledge immediately (Slack requires fast ack)
|
|
93
|
+
if msg[:envelope_id]
|
|
94
|
+
send_ws_frame(ssl, JSON.generate({ envelope_id: msg[:envelope_id] }))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
case msg[:type]
|
|
98
|
+
when "hello"
|
|
99
|
+
# Connection confirmed
|
|
100
|
+
when "disconnect"
|
|
101
|
+
puts "Slack disconnect: #{msg[:reason]}"
|
|
102
|
+
break
|
|
103
|
+
when "events_api"
|
|
104
|
+
handle_event(msg)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
rescue EOFError, IOError, Errno::ECONNRESET, OpenSSL::SSL::SSLError => e
|
|
108
|
+
puts "Socket Mode connection lost: #{e.message}"
|
|
109
|
+
ensure
|
|
110
|
+
ssl&.close rescue nil
|
|
111
|
+
tcp&.close rescue nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def obtain_wss_url
|
|
115
|
+
result = slack_api("apps.connections.open", token: @app_token)
|
|
116
|
+
raise "Failed to obtain WSS URL: #{result["error"]}" unless result["ok"]
|
|
117
|
+
result["url"]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- WebSocket frame reading/writing (RFC 6455 minimal implementation) ---
|
|
121
|
+
|
|
122
|
+
def read_ws_frame(ssl)
|
|
123
|
+
first_byte = ssl.read(1)&.unpack1("C")
|
|
124
|
+
return nil unless first_byte
|
|
125
|
+
|
|
126
|
+
opcode = first_byte & 0x0F
|
|
127
|
+
# Handle ping (opcode 9) → send pong (opcode 10)
|
|
128
|
+
if opcode == 9
|
|
129
|
+
payload = read_ws_payload(ssl)
|
|
130
|
+
send_ws_pong(ssl, payload)
|
|
131
|
+
return nil
|
|
132
|
+
end
|
|
133
|
+
# Close frame (opcode 8)
|
|
134
|
+
return nil if opcode == 8
|
|
135
|
+
# Only process text frames (opcode 1)
|
|
136
|
+
return nil unless opcode == 1
|
|
137
|
+
|
|
138
|
+
read_ws_payload(ssl)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_ws_payload(ssl)
|
|
142
|
+
second_byte = ssl.read(1)&.unpack1("C")
|
|
143
|
+
return nil unless second_byte
|
|
144
|
+
|
|
145
|
+
masked = (second_byte & 0x80) != 0
|
|
146
|
+
length = second_byte & 0x7F
|
|
147
|
+
|
|
148
|
+
if length == 126
|
|
149
|
+
length = ssl.read(2).unpack1("n")
|
|
150
|
+
elsif length == 127
|
|
151
|
+
length = ssl.read(8).unpack1("Q>")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if masked
|
|
155
|
+
mask_key = ssl.read(4).bytes
|
|
156
|
+
raw = ssl.read(length).bytes
|
|
157
|
+
raw.each_with_index.map { |b, i| (b ^ mask_key[i % 4]).chr }.join
|
|
158
|
+
else
|
|
159
|
+
ssl.read(length)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def send_ws_frame(ssl, text)
|
|
164
|
+
bytes = text.encode("UTF-8").bytes
|
|
165
|
+
# Client frames must be masked per RFC 6455
|
|
166
|
+
mask_key = 4.times.map { rand(256) }
|
|
167
|
+
masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
168
|
+
|
|
169
|
+
frame = [0x81].pack("C") # FIN + text opcode
|
|
170
|
+
if bytes.length < 126
|
|
171
|
+
frame << [(bytes.length | 0x80)].pack("C")
|
|
172
|
+
elsif bytes.length < 65536
|
|
173
|
+
frame << [126 | 0x80].pack("C")
|
|
174
|
+
frame << [bytes.length].pack("n")
|
|
175
|
+
else
|
|
176
|
+
frame << [127 | 0x80].pack("C")
|
|
177
|
+
frame << [bytes.length].pack("Q>")
|
|
178
|
+
end
|
|
179
|
+
frame << mask_key.pack("C*")
|
|
180
|
+
frame << masked.pack("C*")
|
|
181
|
+
ssl.write(frame)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def send_ws_pong(ssl, payload)
|
|
185
|
+
payload ||= ""
|
|
186
|
+
bytes = payload.bytes
|
|
187
|
+
mask_key = 4.times.map { rand(256) }
|
|
188
|
+
masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
189
|
+
|
|
190
|
+
frame = [0x8A].pack("C") # FIN + pong opcode
|
|
191
|
+
frame << [(bytes.length | 0x80)].pack("C")
|
|
192
|
+
frame << mask_key.pack("C*")
|
|
193
|
+
frame << masked.pack("C*")
|
|
194
|
+
ssl.write(frame)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- Slack Web API (minimal, uses Net::HTTP) ---
|
|
198
|
+
|
|
199
|
+
def slack_api(method, token: @bot_token, **params)
|
|
200
|
+
uri = URI("https://slack.com/api/#{method}")
|
|
201
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
202
|
+
http.use_ssl = true
|
|
203
|
+
|
|
204
|
+
if params.empty?
|
|
205
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
206
|
+
else
|
|
207
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
208
|
+
req.body = JSON.generate(params)
|
|
209
|
+
req["Content-Type"] = "application/json; charset=utf-8"
|
|
210
|
+
end
|
|
211
|
+
req["Authorization"] = "Bearer #{token}"
|
|
212
|
+
|
|
213
|
+
resp = http.request(req)
|
|
214
|
+
JSON.parse(resp.body)
|
|
215
|
+
rescue => e
|
|
216
|
+
{ "ok" => false, "error" => e.message }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def post_message(channel:, thread_ts:, text:)
|
|
220
|
+
slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# --- Event handling ---
|
|
224
|
+
|
|
225
|
+
def handle_event(msg)
|
|
226
|
+
event = msg.dig(:payload, :event)
|
|
227
|
+
return unless event
|
|
228
|
+
return unless event[:type] == "message"
|
|
229
|
+
|
|
230
|
+
# Ignore bot messages, subtypes (edits/deletes), own messages
|
|
231
|
+
return if event[:bot_id]
|
|
232
|
+
return if event[:user] == @bot_user_id
|
|
233
|
+
return if event[:subtype]
|
|
234
|
+
|
|
235
|
+
text = event[:text]
|
|
236
|
+
return unless text && !text.strip.empty?
|
|
237
|
+
|
|
238
|
+
channel_id = event[:channel]
|
|
239
|
+
return unless watched_channel?(channel_id)
|
|
240
|
+
|
|
241
|
+
thread_ts = event[:thread_ts] || event[:ts]
|
|
242
|
+
user_id = event[:user]
|
|
243
|
+
user_name = resolve_user_name(user_id)
|
|
244
|
+
|
|
245
|
+
allowed_list = Array(RailsConsoleAI.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
|
|
246
|
+
unless allowed_list.include?('all') || allowed_list.include?(user_name.to_s.downcase)
|
|
247
|
+
puts "[#{channel_id}/#{thread_ts}] @#{user_name} << (ignored — not in allowed usernames)"
|
|
248
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{user_name}). Ask an admin to add you to the allowed usernames list.")
|
|
249
|
+
return
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
puts "[#{channel_id}/#{thread_ts}] @#{user_name} << #{text.strip}"
|
|
253
|
+
|
|
254
|
+
session = @mutex.synchronize { @sessions[thread_ts] }
|
|
255
|
+
|
|
256
|
+
command = text.strip.downcase
|
|
257
|
+
if command == 'cancel' || command == 'stop'
|
|
258
|
+
cancel_session(session, channel_id, thread_ts)
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if command == 'clear'
|
|
263
|
+
count = count_bot_messages(channel_id, thread_ts)
|
|
264
|
+
if count == 0
|
|
265
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "No bot messages to clear.")
|
|
266
|
+
else
|
|
267
|
+
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
268
|
+
text: "This will permanently delete #{count} bot message#{'s' unless count == 1} from this thread. Type `clear!` to confirm.")
|
|
269
|
+
end
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if command == 'clear!'
|
|
274
|
+
cancel_session(session, channel_id, thread_ts) if session
|
|
275
|
+
clear_bot_messages(channel_id, thread_ts)
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
if session
|
|
280
|
+
handle_thread_reply(session, text.strip)
|
|
281
|
+
else
|
|
282
|
+
# New thread, or existing thread after bot restart — start a fresh session
|
|
283
|
+
start_session(channel_id, thread_ts, text.strip, user_name)
|
|
284
|
+
end
|
|
285
|
+
rescue => e
|
|
286
|
+
RailsConsoleAI.logger.error("SlackBot event handling error: #{e.class}: #{e.message}")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def start_session(channel_id, thread_ts, text, user_name)
|
|
290
|
+
channel = Channel::Slack.new(
|
|
291
|
+
slack_bot: self,
|
|
292
|
+
channel_id: channel_id,
|
|
293
|
+
thread_ts: thread_ts,
|
|
294
|
+
user_name: user_name
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
sandbox_binding = Object.new.instance_eval { binding }
|
|
298
|
+
engine = ConversationEngine.new(
|
|
299
|
+
binding_context: sandbox_binding,
|
|
300
|
+
channel: channel,
|
|
301
|
+
slack_thread_ts: thread_ts
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Try to restore conversation history from a previous session (e.g. after bot restart)
|
|
305
|
+
restored = restore_from_db(engine, thread_ts)
|
|
306
|
+
|
|
307
|
+
session = { channel: channel, engine: engine, thread: nil }
|
|
308
|
+
@mutex.synchronize { @sessions[thread_ts] = session }
|
|
309
|
+
|
|
310
|
+
session[:thread] = Thread.new do
|
|
311
|
+
Thread.current.report_on_exception = false
|
|
312
|
+
begin
|
|
313
|
+
channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
|
|
314
|
+
if restored
|
|
315
|
+
puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
|
|
316
|
+
channel.display_dim("_(session restored — continuing from previous conversation)_")
|
|
317
|
+
end
|
|
318
|
+
engine.process_message(text)
|
|
319
|
+
rescue => e
|
|
320
|
+
channel.display_error("Error: #{e.class}: #{e.message}")
|
|
321
|
+
RailsConsoleAI.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
322
|
+
ensure
|
|
323
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def restore_from_db(engine, thread_ts)
|
|
329
|
+
require 'rails_console_ai/session_logger'
|
|
330
|
+
saved = SessionLogger.find_by_slack_thread(thread_ts)
|
|
331
|
+
return false unless saved
|
|
332
|
+
|
|
333
|
+
engine.init_interactive
|
|
334
|
+
engine.restore_session(saved)
|
|
335
|
+
true
|
|
336
|
+
rescue => e
|
|
337
|
+
RailsConsoleAI.logger.warn("SlackBot: failed to restore session for #{thread_ts}: #{e.message}")
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def handle_thread_reply(session, text)
|
|
342
|
+
channel = session[:channel]
|
|
343
|
+
engine = session[:engine]
|
|
344
|
+
|
|
345
|
+
# If the engine is blocked waiting for user input (ask_user), push to queue
|
|
346
|
+
if waiting_for_reply?(channel)
|
|
347
|
+
channel.receive_reply(text)
|
|
348
|
+
return
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Otherwise treat as a new message in the conversation
|
|
352
|
+
session[:thread] = Thread.new do
|
|
353
|
+
Thread.current.report_on_exception = false
|
|
354
|
+
begin
|
|
355
|
+
engine.process_message(text)
|
|
356
|
+
rescue => e
|
|
357
|
+
channel.display_error("Error: #{e.class}: #{e.message}")
|
|
358
|
+
RailsConsoleAI.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
359
|
+
ensure
|
|
360
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def cancel_session(session, channel_id, thread_ts)
|
|
366
|
+
if session
|
|
367
|
+
session[:channel].cancel!
|
|
368
|
+
session[:channel].display("Stopped.")
|
|
369
|
+
puts "[#{channel_id}/#{thread_ts}] cancel requested"
|
|
370
|
+
else
|
|
371
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
|
|
372
|
+
puts "[#{channel_id}/#{thread_ts}] cancel: no session"
|
|
373
|
+
end
|
|
374
|
+
@mutex.synchronize { @sessions.delete(thread_ts) }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def count_bot_messages(channel_id, thread_ts)
|
|
378
|
+
result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
|
|
379
|
+
return 0 unless result["ok"]
|
|
380
|
+
(result["messages"] || []).count { |m| m["user"] == @bot_user_id }
|
|
381
|
+
rescue
|
|
382
|
+
0
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def clear_bot_messages(channel_id, thread_ts)
|
|
386
|
+
result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
|
|
387
|
+
unless result["ok"]
|
|
388
|
+
puts "[#{channel_id}/#{thread_ts}] clear: failed to fetch replies: #{result["error"]}"
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
bot_messages = (result["messages"] || []).select { |m| m["user"] == @bot_user_id }
|
|
393
|
+
bot_messages.each do |m|
|
|
394
|
+
puts "[#{channel_id}/#{thread_ts}] clearing #{channel_id.length} / #{m["ts"]}"
|
|
395
|
+
slack_api("chat.delete", channel: channel_id, ts: m["ts"])
|
|
396
|
+
end
|
|
397
|
+
puts "[#{channel_id}/#{thread_ts}] cleared #{bot_messages.length} bot messages"
|
|
398
|
+
rescue => e
|
|
399
|
+
puts "[#{channel_id}/#{thread_ts}] clear failed: #{e.message}"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def slack_get(method, **params)
|
|
403
|
+
uri = URI("https://slack.com/api/#{method}")
|
|
404
|
+
uri.query = URI.encode_www_form(params)
|
|
405
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
406
|
+
http.use_ssl = true
|
|
407
|
+
req = Net::HTTP::Get.new(uri)
|
|
408
|
+
req["Authorization"] = "Bearer #{@bot_token}"
|
|
409
|
+
resp = http.request(req)
|
|
410
|
+
JSON.parse(resp.body)
|
|
411
|
+
rescue => e
|
|
412
|
+
{ "ok" => false, "error" => e.message }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def waiting_for_reply?(channel)
|
|
416
|
+
channel.instance_variable_get(:@reply_queue).num_waiting > 0
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def watched_channel?(channel_id)
|
|
420
|
+
return true if @channel_ids.nil? || @channel_ids.empty?
|
|
421
|
+
@channel_ids.include?(channel_id)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def resolve_channel_ids
|
|
425
|
+
ids = RailsConsoleAI.configuration.slack_channel_ids || ENV['CONSOLE_AGENT_SLACK_CHANNELS']
|
|
426
|
+
return nil if ids.nil?
|
|
427
|
+
ids = ids.split(',').map(&:strip) if ids.is_a?(String)
|
|
428
|
+
ids
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def resolve_user_name(user_id)
|
|
432
|
+
return @user_cache[user_id] if @user_cache.key?(user_id)
|
|
433
|
+
|
|
434
|
+
# users.info requires form-encoded params, not JSON
|
|
435
|
+
uri = URI("https://slack.com/api/users.info")
|
|
436
|
+
uri.query = URI.encode_www_form(user: user_id)
|
|
437
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
438
|
+
http.use_ssl = true
|
|
439
|
+
req = Net::HTTP::Get.new(uri)
|
|
440
|
+
req["Authorization"] = "Bearer #{@bot_token}"
|
|
441
|
+
resp = http.request(req)
|
|
442
|
+
result = JSON.parse(resp.body)
|
|
443
|
+
|
|
444
|
+
name = result.dig("user", "profile", "display_name")
|
|
445
|
+
name = result.dig("user", "real_name") if name.nil? || name.empty?
|
|
446
|
+
name = result.dig("user", "name") if name.nil? || name.empty?
|
|
447
|
+
@user_cache[user_id] = name || user_id
|
|
448
|
+
rescue => e
|
|
449
|
+
RailsConsoleAI.logger.warn("Failed to resolve user name for #{user_id}: #{e.message}")
|
|
450
|
+
@user_cache[user_id] = user_id
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def log_startup
|
|
454
|
+
channel_info = if @channel_ids && !@channel_ids.empty?
|
|
455
|
+
"channels: #{@channel_ids.join(', ')}"
|
|
456
|
+
else
|
|
457
|
+
"all channels"
|
|
458
|
+
end
|
|
459
|
+
puts "RailsConsoleAI SlackBot started (#{channel_info}, bot: #{@bot_user_id})"
|
|
460
|
+
|
|
461
|
+
channel = Channel::Slack.new(slack_bot: self, channel_id: "boot", thread_ts: "boot")
|
|
462
|
+
engine = ConversationEngine.new(
|
|
463
|
+
binding_context: Object.new.instance_eval { binding },
|
|
464
|
+
channel: channel
|
|
465
|
+
)
|
|
466
|
+
puts "\nFull system prompt for Slack sessions:"
|
|
467
|
+
puts "-" * 60
|
|
468
|
+
puts engine.context
|
|
469
|
+
puts "-" * 60
|
|
470
|
+
puts
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module Storage
|
|
3
|
+
class StorageError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class Base
|
|
6
|
+
def read(key)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def write(key, content)
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list(pattern)
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exists?(key)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delete(key)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'rails_console_ai/storage/base'
|
|
3
|
+
|
|
4
|
+
module RailsConsoleAI
|
|
5
|
+
module Storage
|
|
6
|
+
class FileStorage < Base
|
|
7
|
+
attr_reader :root_path
|
|
8
|
+
|
|
9
|
+
def initialize(root_path = nil)
|
|
10
|
+
@root_path = root_path || default_root
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(key)
|
|
14
|
+
path = full_path(key)
|
|
15
|
+
return nil unless File.exist?(path)
|
|
16
|
+
File.read(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(key, content)
|
|
20
|
+
path = full_path(key)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
22
|
+
File.write(path, content)
|
|
23
|
+
true
|
|
24
|
+
rescue Errno::EACCES, Errno::EROFS, IOError => e
|
|
25
|
+
raise StorageError, "Cannot write #{key}: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list(pattern)
|
|
29
|
+
Dir.glob(File.join(@root_path, pattern)).sort.map do |path|
|
|
30
|
+
path.sub("#{@root_path}/", '')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exists?(key)
|
|
35
|
+
File.exist?(full_path(key))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete(key)
|
|
39
|
+
path = full_path(key)
|
|
40
|
+
return false unless File.exist?(path)
|
|
41
|
+
File.delete(path)
|
|
42
|
+
true
|
|
43
|
+
rescue Errno::EACCES, Errno::EROFS, IOError => e
|
|
44
|
+
raise StorageError, "Cannot delete #{key}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def full_path(key)
|
|
50
|
+
sanitized = key.gsub('..', '').gsub(%r{\A/+}, '')
|
|
51
|
+
File.join(@root_path, sanitized)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_root
|
|
55
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
56
|
+
File.join(Rails.root.to_s, '.rails_console_ai')
|
|
57
|
+
else
|
|
58
|
+
File.join(Dir.pwd, '.rails_console_ai')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|