console_agent 0.9.0 → 0.11.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +104 -2
- data/app/helpers/console_agent/sessions_helper.rb +14 -0
- data/app/models/console_agent/session.rb +1 -1
- data/app/views/console_agent/sessions/index.html.erb +4 -4
- data/app/views/console_agent/sessions/show.html.erb +16 -6
- data/app/views/layouts/console_agent/application.html.erb +1 -0
- data/lib/console_agent/channel/base.rb +23 -0
- data/lib/console_agent/channel/console.rb +457 -0
- data/lib/console_agent/channel/slack.rb +182 -0
- data/lib/console_agent/configuration.rb +73 -5
- data/lib/console_agent/conversation_engine.rb +1122 -0
- data/lib/console_agent/executor.rb +257 -44
- data/lib/console_agent/providers/base.rb +23 -15
- data/lib/console_agent/providers/local.rb +112 -0
- data/lib/console_agent/railtie.rb +4 -0
- data/lib/console_agent/repl.rb +27 -1128
- data/lib/console_agent/safety_guards.rb +207 -0
- data/lib/console_agent/session_logger.rb +14 -3
- data/lib/console_agent/slack_bot.rb +465 -0
- data/lib/console_agent/tools/registry.rb +66 -16
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +17 -3
- data/lib/generators/console_agent/templates/initializer.rb +30 -1
- data/lib/tasks/console_agent.rake +7 -0
- metadata +9 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
# Raised by safety guards to block dangerous operations.
|
|
3
|
+
# Host apps should raise this error in their custom guards.
|
|
4
|
+
# ConsoleAgent will catch it and guide the user to use 'd' or /danger.
|
|
5
|
+
class SafetyError < StandardError
|
|
6
|
+
attr_reader :guard, :blocked_key
|
|
7
|
+
|
|
8
|
+
def initialize(message, guard: nil, blocked_key: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@guard = guard
|
|
11
|
+
@blocked_key = blocked_key
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class SafetyGuards
|
|
16
|
+
attr_reader :guards
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@guards = {}
|
|
20
|
+
@enabled = true
|
|
21
|
+
@allowlist = {} # { guard_name => [String or Regexp, ...] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add(name, &block)
|
|
25
|
+
@guards[name.to_sym] = block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def remove(name)
|
|
29
|
+
@guards.delete(name.to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
@enabled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enable!
|
|
37
|
+
@enabled = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def disable!
|
|
41
|
+
@enabled = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def empty?
|
|
45
|
+
@guards.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def names
|
|
49
|
+
@guards.keys
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def allow(guard_name, key)
|
|
53
|
+
guard_name = guard_name.to_sym
|
|
54
|
+
@allowlist[guard_name] ||= []
|
|
55
|
+
@allowlist[guard_name] << key unless @allowlist[guard_name].include?(key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def allowed?(guard_name, key)
|
|
59
|
+
entries = @allowlist[guard_name.to_sym]
|
|
60
|
+
return false unless entries
|
|
61
|
+
|
|
62
|
+
entries.any? do |entry|
|
|
63
|
+
case entry
|
|
64
|
+
when Regexp then key.match?(entry)
|
|
65
|
+
else entry.to_s == key.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def allowlist
|
|
71
|
+
@allowlist
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Compose all guards around a block of code.
|
|
75
|
+
# Each guard is an around-block: guard.call { inner }
|
|
76
|
+
# Result: guard_1 { guard_2 { guard_3 { yield } } }
|
|
77
|
+
def wrap(&block)
|
|
78
|
+
return yield unless @enabled && !@guards.empty?
|
|
79
|
+
|
|
80
|
+
@guards.values.reduce(block) { |inner, guard|
|
|
81
|
+
-> { guard.call(&inner) }
|
|
82
|
+
}.call
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Built-in guard: database write prevention
|
|
87
|
+
# Works on all Rails versions (5+) and all database adapters.
|
|
88
|
+
# Prepends a write-intercepting module once, controlled by a thread-local flag.
|
|
89
|
+
module BuiltinGuards
|
|
90
|
+
# Blocks INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE
|
|
91
|
+
module WriteBlocker
|
|
92
|
+
WRITE_PATTERN = /\A\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)\b/i
|
|
93
|
+
TABLE_PATTERN = /\b(?:INTO|FROM|UPDATE|TABLE|TRUNCATE)\s+[`"]?(\w+)[`"]?/i
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def console_agent_check_write!(sql)
|
|
98
|
+
return unless Thread.current[:console_agent_block_writes] && sql.match?(WRITE_PATTERN)
|
|
99
|
+
|
|
100
|
+
table = sql.match(TABLE_PATTERN)&.captures&.first
|
|
101
|
+
guards = ConsoleAgent.configuration.safety_guards
|
|
102
|
+
return if table && guards.allowed?(:database_writes, table)
|
|
103
|
+
|
|
104
|
+
raise ConsoleAgent::SafetyError.new(
|
|
105
|
+
"Database write blocked: #{sql.strip.split(/\s+/).first(3).join(' ')}...",
|
|
106
|
+
guard: :database_writes,
|
|
107
|
+
blocked_key: table
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
public
|
|
112
|
+
|
|
113
|
+
def execute(sql, *args, **kwargs)
|
|
114
|
+
console_agent_check_write!(sql)
|
|
115
|
+
super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def exec_delete(sql, *args, **kwargs)
|
|
119
|
+
console_agent_check_write!(sql)
|
|
120
|
+
super
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def exec_update(sql, *args, **kwargs)
|
|
124
|
+
console_agent_check_write!(sql)
|
|
125
|
+
super
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.database_writes
|
|
130
|
+
->(& block) {
|
|
131
|
+
ensure_write_blocker_installed!
|
|
132
|
+
Thread.current[:console_agent_block_writes] = true
|
|
133
|
+
begin
|
|
134
|
+
block.call
|
|
135
|
+
ensure
|
|
136
|
+
Thread.current[:console_agent_block_writes] = false
|
|
137
|
+
end
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.ensure_write_blocker_installed!
|
|
142
|
+
return if @write_blocker_installed
|
|
143
|
+
|
|
144
|
+
connection = ActiveRecord::Base.connection
|
|
145
|
+
unless connection.class.ancestors.include?(WriteBlocker)
|
|
146
|
+
connection.class.prepend(WriteBlocker)
|
|
147
|
+
end
|
|
148
|
+
@write_blocker_installed = true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Blocks non-safe HTTP requests (POST, PUT, PATCH, DELETE, etc.) via Net::HTTP.
|
|
152
|
+
# Since most Ruby HTTP libraries (HTTParty, RestClient, Faraday) use Net::HTTP
|
|
153
|
+
# under the hood, this covers them all.
|
|
154
|
+
module HttpBlocker
|
|
155
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
156
|
+
|
|
157
|
+
def request(req, *args, &block)
|
|
158
|
+
if Thread.current[:console_agent_block_http] && !SAFE_METHODS.include?(req.method)
|
|
159
|
+
host = @address.to_s
|
|
160
|
+
guards = ConsoleAgent.configuration.safety_guards
|
|
161
|
+
unless guards.allowed?(:http_mutations, host)
|
|
162
|
+
raise ConsoleAgent::SafetyError.new(
|
|
163
|
+
"HTTP #{req.method} blocked (#{host}#{req.path})",
|
|
164
|
+
guard: :http_mutations,
|
|
165
|
+
blocked_key: host
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
super
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.http_mutations
|
|
174
|
+
->(&block) {
|
|
175
|
+
ensure_http_blocker_installed!
|
|
176
|
+
Thread.current[:console_agent_block_http] = true
|
|
177
|
+
begin
|
|
178
|
+
block.call
|
|
179
|
+
ensure
|
|
180
|
+
Thread.current[:console_agent_block_http] = false
|
|
181
|
+
end
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.mailers
|
|
186
|
+
->(&block) {
|
|
187
|
+
old_value = ActionMailer::Base.perform_deliveries
|
|
188
|
+
ActionMailer::Base.perform_deliveries = false
|
|
189
|
+
begin
|
|
190
|
+
block.call
|
|
191
|
+
ensure
|
|
192
|
+
ActionMailer::Base.perform_deliveries = old_value
|
|
193
|
+
end
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.ensure_http_blocker_installed!
|
|
198
|
+
return if @http_blocker_installed
|
|
199
|
+
|
|
200
|
+
require 'net/http'
|
|
201
|
+
unless Net::HTTP.ancestors.include?(HttpBlocker)
|
|
202
|
+
Net::HTTP.prepend(HttpBlocker)
|
|
203
|
+
end
|
|
204
|
+
@http_blocker_installed = true
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -5,12 +5,12 @@ module ConsoleAgent
|
|
|
5
5
|
return unless ConsoleAgent.configuration.session_logging
|
|
6
6
|
return unless table_exists?
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
create_attrs = {
|
|
9
9
|
query: attrs[:query],
|
|
10
10
|
conversation: Array(attrs[:conversation]).to_json,
|
|
11
11
|
input_tokens: attrs[:input_tokens] || 0,
|
|
12
12
|
output_tokens: attrs[:output_tokens] || 0,
|
|
13
|
-
user_name: current_user_name,
|
|
13
|
+
user_name: attrs[:user_name] || current_user_name,
|
|
14
14
|
mode: attrs[:mode].to_s,
|
|
15
15
|
name: attrs[:name],
|
|
16
16
|
code_executed: attrs[:code_executed],
|
|
@@ -22,7 +22,9 @@ module ConsoleAgent
|
|
|
22
22
|
model: ConsoleAgent.configuration.resolved_model,
|
|
23
23
|
duration_ms: attrs[:duration_ms],
|
|
24
24
|
created_at: Time.respond_to?(:current) ? Time.current : Time.now
|
|
25
|
-
|
|
25
|
+
}
|
|
26
|
+
create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
|
|
27
|
+
record = session_class.create!(create_attrs)
|
|
26
28
|
record.id
|
|
27
29
|
rescue => e
|
|
28
30
|
msg = "ConsoleAgent: session logging failed: #{e.class}: #{e.message}"
|
|
@@ -31,6 +33,15 @@ module ConsoleAgent
|
|
|
31
33
|
nil
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
def find_by_slack_thread(thread_ts)
|
|
37
|
+
return nil unless ConsoleAgent.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
|
+
ConsoleAgent.logger.warn("ConsoleAgent: session lookup failed: #{e.class}: #{e.message}")
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
def update(id, attrs)
|
|
35
46
|
return unless id
|
|
36
47
|
return unless ConsoleAgent.configuration.session_logging
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'console_agent/channel/slack'
|
|
6
|
+
require 'console_agent/conversation_engine'
|
|
7
|
+
require 'console_agent/context_builder'
|
|
8
|
+
require 'console_agent/providers/base'
|
|
9
|
+
require 'console_agent/executor'
|
|
10
|
+
|
|
11
|
+
module ConsoleAgent
|
|
12
|
+
class SlackBot
|
|
13
|
+
def initialize
|
|
14
|
+
@bot_token = ConsoleAgent.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
|
|
15
|
+
@app_token = ConsoleAgent.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
|
+
|
|
21
|
+
@bot_user_id = nil
|
|
22
|
+
@sessions = {} # thread_ts → { channel:, engine:, thread: }
|
|
23
|
+
@user_cache = {} # slack user_id → display_name
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start
|
|
28
|
+
@bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
|
|
29
|
+
log_startup
|
|
30
|
+
|
|
31
|
+
loop do
|
|
32
|
+
run_socket_mode
|
|
33
|
+
puts "Reconnecting in 5s..."
|
|
34
|
+
sleep 5
|
|
35
|
+
end
|
|
36
|
+
rescue Interrupt
|
|
37
|
+
puts "\nSlackBot shutting down."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# --- Socket Mode connection ---
|
|
43
|
+
|
|
44
|
+
def run_socket_mode
|
|
45
|
+
url = obtain_wss_url
|
|
46
|
+
uri = URI.parse(url)
|
|
47
|
+
|
|
48
|
+
tcp = TCPSocket.new(uri.host, uri.port || 443)
|
|
49
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
|
|
50
|
+
ssl.hostname = uri.host
|
|
51
|
+
ssl.connect
|
|
52
|
+
|
|
53
|
+
# WebSocket handshake
|
|
54
|
+
path = "#{uri.path}?#{uri.query}"
|
|
55
|
+
handshake = [
|
|
56
|
+
"GET #{path} HTTP/1.1",
|
|
57
|
+
"Host: #{uri.host}",
|
|
58
|
+
"Upgrade: websocket",
|
|
59
|
+
"Connection: Upgrade",
|
|
60
|
+
"Sec-WebSocket-Key: #{SecureRandom.base64(16)}",
|
|
61
|
+
"Sec-WebSocket-Version: 13",
|
|
62
|
+
"", ""
|
|
63
|
+
].join("\r\n")
|
|
64
|
+
|
|
65
|
+
ssl.write(handshake)
|
|
66
|
+
|
|
67
|
+
# Read HTTP 101 response headers
|
|
68
|
+
response_line = ssl.gets
|
|
69
|
+
unless response_line&.include?("101")
|
|
70
|
+
raise "WebSocket handshake failed: #{response_line}"
|
|
71
|
+
end
|
|
72
|
+
# Consume remaining headers
|
|
73
|
+
loop do
|
|
74
|
+
line = ssl.gets
|
|
75
|
+
break if line.nil? || line.strip.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
puts "Connected to Slack Socket Mode."
|
|
79
|
+
|
|
80
|
+
# Main read loop
|
|
81
|
+
loop do
|
|
82
|
+
data = read_ws_frame(ssl)
|
|
83
|
+
next unless data
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
msg = JSON.parse(data, symbolize_names: true)
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Acknowledge immediately (Slack requires fast ack)
|
|
92
|
+
if msg[:envelope_id]
|
|
93
|
+
send_ws_frame(ssl, JSON.generate({ envelope_id: msg[:envelope_id] }))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
case msg[:type]
|
|
97
|
+
when "hello"
|
|
98
|
+
# Connection confirmed
|
|
99
|
+
when "disconnect"
|
|
100
|
+
puts "Slack disconnect: #{msg[:reason]}"
|
|
101
|
+
break
|
|
102
|
+
when "events_api"
|
|
103
|
+
handle_event(msg)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
rescue EOFError, IOError, Errno::ECONNRESET, OpenSSL::SSL::SSLError => e
|
|
107
|
+
puts "Socket Mode connection lost: #{e.message}"
|
|
108
|
+
ensure
|
|
109
|
+
ssl&.close rescue nil
|
|
110
|
+
tcp&.close rescue nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def obtain_wss_url
|
|
114
|
+
result = slack_api("apps.connections.open", token: @app_token)
|
|
115
|
+
raise "Failed to obtain WSS URL: #{result["error"]}" unless result["ok"]
|
|
116
|
+
result["url"]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- WebSocket frame reading/writing (RFC 6455 minimal implementation) ---
|
|
120
|
+
|
|
121
|
+
def read_ws_frame(ssl)
|
|
122
|
+
first_byte = ssl.read(1)&.unpack1("C")
|
|
123
|
+
return nil unless first_byte
|
|
124
|
+
|
|
125
|
+
opcode = first_byte & 0x0F
|
|
126
|
+
# Handle ping (opcode 9) → send pong (opcode 10)
|
|
127
|
+
if opcode == 9
|
|
128
|
+
payload = read_ws_payload(ssl)
|
|
129
|
+
send_ws_pong(ssl, payload)
|
|
130
|
+
return nil
|
|
131
|
+
end
|
|
132
|
+
# Close frame (opcode 8)
|
|
133
|
+
return nil if opcode == 8
|
|
134
|
+
# Only process text frames (opcode 1)
|
|
135
|
+
return nil unless opcode == 1
|
|
136
|
+
|
|
137
|
+
read_ws_payload(ssl)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_ws_payload(ssl)
|
|
141
|
+
second_byte = ssl.read(1)&.unpack1("C")
|
|
142
|
+
return nil unless second_byte
|
|
143
|
+
|
|
144
|
+
masked = (second_byte & 0x80) != 0
|
|
145
|
+
length = second_byte & 0x7F
|
|
146
|
+
|
|
147
|
+
if length == 126
|
|
148
|
+
length = ssl.read(2).unpack1("n")
|
|
149
|
+
elsif length == 127
|
|
150
|
+
length = ssl.read(8).unpack1("Q>")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if masked
|
|
154
|
+
mask_key = ssl.read(4).bytes
|
|
155
|
+
raw = ssl.read(length).bytes
|
|
156
|
+
raw.each_with_index.map { |b, i| (b ^ mask_key[i % 4]).chr }.join
|
|
157
|
+
else
|
|
158
|
+
ssl.read(length)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def send_ws_frame(ssl, text)
|
|
163
|
+
bytes = text.encode("UTF-8").bytes
|
|
164
|
+
# Client frames must be masked per RFC 6455
|
|
165
|
+
mask_key = 4.times.map { rand(256) }
|
|
166
|
+
masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
167
|
+
|
|
168
|
+
frame = [0x81].pack("C") # FIN + text opcode
|
|
169
|
+
if bytes.length < 126
|
|
170
|
+
frame << [(bytes.length | 0x80)].pack("C")
|
|
171
|
+
elsif bytes.length < 65536
|
|
172
|
+
frame << [126 | 0x80].pack("C")
|
|
173
|
+
frame << [bytes.length].pack("n")
|
|
174
|
+
else
|
|
175
|
+
frame << [127 | 0x80].pack("C")
|
|
176
|
+
frame << [bytes.length].pack("Q>")
|
|
177
|
+
end
|
|
178
|
+
frame << mask_key.pack("C*")
|
|
179
|
+
frame << masked.pack("C*")
|
|
180
|
+
ssl.write(frame)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def send_ws_pong(ssl, payload)
|
|
184
|
+
payload ||= ""
|
|
185
|
+
bytes = payload.bytes
|
|
186
|
+
mask_key = 4.times.map { rand(256) }
|
|
187
|
+
masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
188
|
+
|
|
189
|
+
frame = [0x8A].pack("C") # FIN + pong opcode
|
|
190
|
+
frame << [(bytes.length | 0x80)].pack("C")
|
|
191
|
+
frame << mask_key.pack("C*")
|
|
192
|
+
frame << masked.pack("C*")
|
|
193
|
+
ssl.write(frame)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- Slack Web API (minimal, uses Net::HTTP) ---
|
|
197
|
+
|
|
198
|
+
def slack_api(method, token: @bot_token, **params)
|
|
199
|
+
uri = URI("https://slack.com/api/#{method}")
|
|
200
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
201
|
+
http.use_ssl = true
|
|
202
|
+
|
|
203
|
+
if params.empty?
|
|
204
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
205
|
+
else
|
|
206
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
207
|
+
req.body = JSON.generate(params)
|
|
208
|
+
req["Content-Type"] = "application/json; charset=utf-8"
|
|
209
|
+
end
|
|
210
|
+
req["Authorization"] = "Bearer #{token}"
|
|
211
|
+
|
|
212
|
+
resp = http.request(req)
|
|
213
|
+
JSON.parse(resp.body)
|
|
214
|
+
rescue => e
|
|
215
|
+
{ "ok" => false, "error" => e.message }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def post_message(channel:, thread_ts:, text:)
|
|
219
|
+
slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# --- Event handling ---
|
|
223
|
+
|
|
224
|
+
def handle_event(msg)
|
|
225
|
+
event = msg.dig(:payload, :event)
|
|
226
|
+
return unless event
|
|
227
|
+
return unless event[:type] == "message"
|
|
228
|
+
|
|
229
|
+
# Ignore bot messages, subtypes (edits/deletes), own messages
|
|
230
|
+
return if event[:bot_id]
|
|
231
|
+
return if event[:user] == @bot_user_id
|
|
232
|
+
return if event[:subtype]
|
|
233
|
+
|
|
234
|
+
text = event[:text]
|
|
235
|
+
return unless text && !text.strip.empty?
|
|
236
|
+
|
|
237
|
+
channel_id = event[:channel]
|
|
238
|
+
return unless watched_channel?(channel_id)
|
|
239
|
+
|
|
240
|
+
thread_ts = event[:thread_ts] || event[:ts]
|
|
241
|
+
user_id = event[:user]
|
|
242
|
+
user_name = resolve_user_name(user_id)
|
|
243
|
+
|
|
244
|
+
puts "[#{channel_id}/#{thread_ts}] @#{user_name} << #{text.strip}"
|
|
245
|
+
|
|
246
|
+
session = @mutex.synchronize { @sessions[thread_ts] }
|
|
247
|
+
|
|
248
|
+
command = text.strip.downcase
|
|
249
|
+
if command == 'cancel' || command == 'stop'
|
|
250
|
+
cancel_session(session, channel_id, thread_ts)
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if command == 'clear'
|
|
255
|
+
count = count_bot_messages(channel_id, thread_ts)
|
|
256
|
+
if count == 0
|
|
257
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "No bot messages to clear.")
|
|
258
|
+
else
|
|
259
|
+
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
260
|
+
text: "This will permanently delete #{count} bot message#{'s' unless count == 1} from this thread. Type `clear!` to confirm.")
|
|
261
|
+
end
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if command == 'clear!'
|
|
266
|
+
cancel_session(session, channel_id, thread_ts) if session
|
|
267
|
+
clear_bot_messages(channel_id, thread_ts)
|
|
268
|
+
return
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
if session
|
|
272
|
+
handle_thread_reply(session, text.strip)
|
|
273
|
+
else
|
|
274
|
+
# New thread, or existing thread after bot restart — start a fresh session
|
|
275
|
+
start_session(channel_id, thread_ts, text.strip, user_name)
|
|
276
|
+
end
|
|
277
|
+
rescue => e
|
|
278
|
+
ConsoleAgent.logger.error("SlackBot event handling error: #{e.class}: #{e.message}")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def start_session(channel_id, thread_ts, text, user_name)
|
|
282
|
+
channel = Channel::Slack.new(
|
|
283
|
+
slack_bot: self,
|
|
284
|
+
channel_id: channel_id,
|
|
285
|
+
thread_ts: thread_ts,
|
|
286
|
+
user_name: user_name
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
sandbox_binding = Object.new.instance_eval { binding }
|
|
290
|
+
engine = ConversationEngine.new(
|
|
291
|
+
binding_context: sandbox_binding,
|
|
292
|
+
channel: channel,
|
|
293
|
+
slack_thread_ts: thread_ts
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Try to restore conversation history from a previous session (e.g. after bot restart)
|
|
297
|
+
restored = restore_from_db(engine, thread_ts)
|
|
298
|
+
|
|
299
|
+
session = { channel: channel, engine: engine, thread: nil }
|
|
300
|
+
@mutex.synchronize { @sessions[thread_ts] = session }
|
|
301
|
+
|
|
302
|
+
session[:thread] = Thread.new do
|
|
303
|
+
Thread.current.report_on_exception = false
|
|
304
|
+
begin
|
|
305
|
+
channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
|
|
306
|
+
if restored
|
|
307
|
+
puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
|
|
308
|
+
channel.display_dim("_(session restored — continuing from previous conversation)_")
|
|
309
|
+
end
|
|
310
|
+
engine.process_message(text)
|
|
311
|
+
rescue => e
|
|
312
|
+
channel.display_error("Error: #{e.class}: #{e.message}")
|
|
313
|
+
ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
314
|
+
ensure
|
|
315
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def restore_from_db(engine, thread_ts)
|
|
321
|
+
require 'console_agent/session_logger'
|
|
322
|
+
saved = SessionLogger.find_by_slack_thread(thread_ts)
|
|
323
|
+
return false unless saved
|
|
324
|
+
|
|
325
|
+
engine.init_interactive
|
|
326
|
+
engine.restore_session(saved)
|
|
327
|
+
true
|
|
328
|
+
rescue => e
|
|
329
|
+
ConsoleAgent.logger.warn("SlackBot: failed to restore session for #{thread_ts}: #{e.message}")
|
|
330
|
+
false
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def handle_thread_reply(session, text)
|
|
334
|
+
channel = session[:channel]
|
|
335
|
+
engine = session[:engine]
|
|
336
|
+
|
|
337
|
+
# If the engine is blocked waiting for user input (ask_user), push to queue
|
|
338
|
+
if waiting_for_reply?(channel)
|
|
339
|
+
channel.receive_reply(text)
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Otherwise treat as a new message in the conversation
|
|
344
|
+
session[:thread] = Thread.new do
|
|
345
|
+
Thread.current.report_on_exception = false
|
|
346
|
+
begin
|
|
347
|
+
engine.process_message(text)
|
|
348
|
+
rescue => e
|
|
349
|
+
channel.display_error("Error: #{e.class}: #{e.message}")
|
|
350
|
+
ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
351
|
+
ensure
|
|
352
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def cancel_session(session, channel_id, thread_ts)
|
|
358
|
+
if session
|
|
359
|
+
session[:channel].cancel!
|
|
360
|
+
session[:channel].display("Stopped.")
|
|
361
|
+
puts "[#{channel_id}/#{thread_ts}] cancel requested"
|
|
362
|
+
else
|
|
363
|
+
post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
|
|
364
|
+
puts "[#{channel_id}/#{thread_ts}] cancel: no session"
|
|
365
|
+
end
|
|
366
|
+
@mutex.synchronize { @sessions.delete(thread_ts) }
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def count_bot_messages(channel_id, thread_ts)
|
|
370
|
+
result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
|
|
371
|
+
return 0 unless result["ok"]
|
|
372
|
+
(result["messages"] || []).count { |m| m["user"] == @bot_user_id }
|
|
373
|
+
rescue
|
|
374
|
+
0
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def clear_bot_messages(channel_id, thread_ts)
|
|
378
|
+
result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
|
|
379
|
+
unless result["ok"]
|
|
380
|
+
puts "[#{channel_id}/#{thread_ts}] clear: failed to fetch replies: #{result["error"]}"
|
|
381
|
+
return
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
bot_messages = (result["messages"] || []).select { |m| m["user"] == @bot_user_id }
|
|
385
|
+
bot_messages.each do |m|
|
|
386
|
+
puts "[#{channel_id}/#{thread_ts}] clearing #{channel_id.length} / #{m["ts"]}"
|
|
387
|
+
slack_api("chat.delete", channel: channel_id, ts: m["ts"])
|
|
388
|
+
end
|
|
389
|
+
puts "[#{channel_id}/#{thread_ts}] cleared #{bot_messages.length} bot messages"
|
|
390
|
+
rescue => e
|
|
391
|
+
puts "[#{channel_id}/#{thread_ts}] clear failed: #{e.message}"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def slack_get(method, **params)
|
|
395
|
+
uri = URI("https://slack.com/api/#{method}")
|
|
396
|
+
uri.query = URI.encode_www_form(params)
|
|
397
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
398
|
+
http.use_ssl = true
|
|
399
|
+
req = Net::HTTP::Get.new(uri)
|
|
400
|
+
req["Authorization"] = "Bearer #{@bot_token}"
|
|
401
|
+
resp = http.request(req)
|
|
402
|
+
JSON.parse(resp.body)
|
|
403
|
+
rescue => e
|
|
404
|
+
{ "ok" => false, "error" => e.message }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def waiting_for_reply?(channel)
|
|
408
|
+
channel.instance_variable_get(:@reply_queue).num_waiting > 0
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def watched_channel?(channel_id)
|
|
412
|
+
return true if @channel_ids.nil? || @channel_ids.empty?
|
|
413
|
+
@channel_ids.include?(channel_id)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def resolve_channel_ids
|
|
417
|
+
ids = ConsoleAgent.configuration.slack_channel_ids || ENV['CONSOLE_AGENT_SLACK_CHANNELS']
|
|
418
|
+
return nil if ids.nil?
|
|
419
|
+
ids = ids.split(',').map(&:strip) if ids.is_a?(String)
|
|
420
|
+
ids
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def resolve_user_name(user_id)
|
|
424
|
+
return @user_cache[user_id] if @user_cache.key?(user_id)
|
|
425
|
+
|
|
426
|
+
# users.info requires form-encoded params, not JSON
|
|
427
|
+
uri = URI("https://slack.com/api/users.info")
|
|
428
|
+
uri.query = URI.encode_www_form(user: user_id)
|
|
429
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
430
|
+
http.use_ssl = true
|
|
431
|
+
req = Net::HTTP::Get.new(uri)
|
|
432
|
+
req["Authorization"] = "Bearer #{@bot_token}"
|
|
433
|
+
resp = http.request(req)
|
|
434
|
+
result = JSON.parse(resp.body)
|
|
435
|
+
|
|
436
|
+
name = result.dig("user", "profile", "display_name")
|
|
437
|
+
name = result.dig("user", "real_name") if name.nil? || name.empty?
|
|
438
|
+
name = result.dig("user", "name") if name.nil? || name.empty?
|
|
439
|
+
@user_cache[user_id] = name || user_id
|
|
440
|
+
rescue => e
|
|
441
|
+
ConsoleAgent.logger.warn("Failed to resolve user name for #{user_id}: #{e.message}")
|
|
442
|
+
@user_cache[user_id] = user_id
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def log_startup
|
|
446
|
+
channel_info = if @channel_ids && !@channel_ids.empty?
|
|
447
|
+
"channels: #{@channel_ids.join(', ')}"
|
|
448
|
+
else
|
|
449
|
+
"all channels"
|
|
450
|
+
end
|
|
451
|
+
puts "ConsoleAgent SlackBot started (#{channel_info}, bot: #{@bot_user_id})"
|
|
452
|
+
|
|
453
|
+
channel = Channel::Slack.new(slack_bot: self, channel_id: "boot", thread_ts: "boot")
|
|
454
|
+
engine = ConversationEngine.new(
|
|
455
|
+
binding_context: Object.new.instance_eval { binding },
|
|
456
|
+
channel: channel
|
|
457
|
+
)
|
|
458
|
+
puts "\nFull system prompt for Slack sessions:"
|
|
459
|
+
puts "-" * 60
|
|
460
|
+
puts engine.context
|
|
461
|
+
puts "-" * 60
|
|
462
|
+
puts
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|