collavre_openclaw 0.1.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/README.md +285 -0
- data/Rakefile +11 -0
- data/app/controllers/collavre_openclaw/application_controller.rb +22 -0
- data/app/controllers/collavre_openclaw/callbacks_controller.rb +78 -0
- data/app/controllers/collavre_openclaw/health_controller.rb +13 -0
- data/app/jobs/collavre_openclaw/application_job.rb +5 -0
- data/app/jobs/collavre_openclaw/callback_processor_job.rb +124 -0
- data/app/models/collavre_openclaw/application_record.rb +5 -0
- data/app/models/collavre_openclaw/pending_callback.rb +53 -0
- data/app/services/collavre_openclaw/ai_client_extension.rb +61 -0
- data/app/services/collavre_openclaw/openclaw_adapter.rb +422 -0
- data/config/initializers/ai_client_extension.rb +15 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/ko.yml +6 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260131000001_create_openclaw_accounts.rb +14 -0
- data/db/migrate/20260201000001_create_pending_callbacks.rb +17 -0
- data/db/migrate/20260202000001_remove_webhook_secret_from_openclaw_accounts.rb +5 -0
- data/db/migrate/20260202074949_add_agent_id_to_openclaw_accounts.rb +6 -0
- data/lib/collavre_openclaw/configuration.rb +28 -0
- data/lib/collavre_openclaw/engine.rb +44 -0
- data/lib/collavre_openclaw/version.rb +3 -0
- data/lib/collavre_openclaw.rb +13 -0
- metadata +93 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module CollavreOpenclaw
|
|
5
|
+
class OpenclawAdapter
|
|
6
|
+
# Adapter for OpenClaw AI Gateway
|
|
7
|
+
# Uses OpenAI-compatible /v1/chat/completions endpoint
|
|
8
|
+
#
|
|
9
|
+
# Session mapping:
|
|
10
|
+
# Collavre Topic → OpenClaw Session (1:1)
|
|
11
|
+
# Same Topic, multiple users → shared context
|
|
12
|
+
# Different Topics → isolated sessions
|
|
13
|
+
|
|
14
|
+
def initialize(user:, system_prompt:, context: {})
|
|
15
|
+
@user = user
|
|
16
|
+
@system_prompt = system_prompt
|
|
17
|
+
@context = context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def chat(messages, tools: [], &block)
|
|
21
|
+
unless @user&.gateway_url.present?
|
|
22
|
+
Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
|
|
23
|
+
yield "Error: OpenClaw Gateway URL not configured" if block_given?
|
|
24
|
+
return nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
response_content = +""
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
# Build the request payload (OpenAI format)
|
|
31
|
+
payload = build_payload(messages, tools)
|
|
32
|
+
|
|
33
|
+
Rails.logger.info("[CollavreOpenclaw] Sending request to #{api_endpoint} (session: #{session_key})")
|
|
34
|
+
|
|
35
|
+
# Make streaming request to OpenClaw
|
|
36
|
+
stream_response(payload) do |chunk|
|
|
37
|
+
response_content << chunk
|
|
38
|
+
yield chunk if block_given?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
response_content.presence
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Rails.logger.error("[CollavreOpenclaw] Chat error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
44
|
+
error_msg = "OpenClaw Error: #{e.message}"
|
|
45
|
+
yield error_msg if block_given?
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get the callback URL for this user
|
|
51
|
+
def callback_url
|
|
52
|
+
return nil unless @user
|
|
53
|
+
|
|
54
|
+
host_options = default_url_options
|
|
55
|
+
return nil if host_options[:host].blank?
|
|
56
|
+
|
|
57
|
+
CollavreOpenclaw::Engine.routes.url_helpers.callback_url(
|
|
58
|
+
user_id: @user.id,
|
|
59
|
+
**host_options
|
|
60
|
+
)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger.warn("[CollavreOpenclaw] Failed to generate callback URL: #{e.message}")
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the stable session key for this context
|
|
67
|
+
def session_key
|
|
68
|
+
@session_key ||= build_session_key
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def api_endpoint
|
|
74
|
+
# OpenClaw uses /v1/chat/completions (OpenAI-compatible)
|
|
75
|
+
uri = URI.parse(@user.gateway_url)
|
|
76
|
+
uri.path = "/v1/chat/completions"
|
|
77
|
+
uri.to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build stable session key based on Topic (not nonce)
|
|
81
|
+
# Same Topic = Same Session = Shared context between users
|
|
82
|
+
# Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
|
|
83
|
+
def build_session_key
|
|
84
|
+
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
85
|
+
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
86
|
+
agent_id = extract_agent_id_from_email || "main"
|
|
87
|
+
|
|
88
|
+
parts = [ "agent", agent_id, "collavre", @user.id ]
|
|
89
|
+
parts << "creative:#{creative_id}" if creative_id
|
|
90
|
+
parts << "topic:#{topic_id}" if topic_id
|
|
91
|
+
|
|
92
|
+
parts.join(":")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_payload(messages, tools)
|
|
96
|
+
# Build model string with agent_id derived from user email
|
|
97
|
+
# OpenClaw accepts "openclaw:<agentId>" format (e.g., "openclaw:collavre")
|
|
98
|
+
agent_id = extract_agent_id_from_email
|
|
99
|
+
model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
|
|
100
|
+
|
|
101
|
+
payload = {
|
|
102
|
+
model: model_value,
|
|
103
|
+
messages: format_messages(messages),
|
|
104
|
+
stream: true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Add system prompt as first message if present
|
|
108
|
+
if @system_prompt.present?
|
|
109
|
+
payload[:messages].unshift({ role: "system", content: @system_prompt })
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Add tools if provided (convert to OpenAI function calling format)
|
|
113
|
+
if tools.present?
|
|
114
|
+
payload[:tools] = format_tools(tools)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Build user context with callback information
|
|
118
|
+
payload[:user] = build_user_context
|
|
119
|
+
|
|
120
|
+
payload
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Convert tools to OpenAI function calling format
|
|
124
|
+
# Accepts either:
|
|
125
|
+
# - Array of tool names (strings): ["meta_tool", "search"]
|
|
126
|
+
# - Array of OpenAI-format tool objects (already formatted)
|
|
127
|
+
def format_tools(tools)
|
|
128
|
+
Array(tools).filter_map do |tool|
|
|
129
|
+
if tool.is_a?(String)
|
|
130
|
+
# Tool name - fetch from MCP and convert to OpenAI format
|
|
131
|
+
convert_tool_name_to_openai_format(tool)
|
|
132
|
+
elsif tool.is_a?(Hash)
|
|
133
|
+
# Already a hash - check if it's OpenAI format or needs conversion
|
|
134
|
+
if tool[:type] == "function" || tool["type"] == "function"
|
|
135
|
+
# Already OpenAI format
|
|
136
|
+
tool
|
|
137
|
+
else
|
|
138
|
+
# MCP format - convert to OpenAI format
|
|
139
|
+
convert_mcp_tool_to_openai_format(tool)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end.compact
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Convert a tool name to OpenAI function format by fetching from MCP
|
|
146
|
+
def convert_tool_name_to_openai_format(tool_name)
|
|
147
|
+
return nil unless defined?(::Tools::MetaToolService)
|
|
148
|
+
|
|
149
|
+
result = ::Tools::MetaToolService.new.call(action: "get", tool_name: tool_name, query: nil, arguments: nil)
|
|
150
|
+
return nil if result[:error] || result[:tool].nil?
|
|
151
|
+
|
|
152
|
+
convert_mcp_tool_to_openai_format(result[:tool])
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
Rails.logger.warn("[CollavreOpenclaw] Failed to fetch tool #{tool_name}: #{e.message}")
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Convert MCP tool format to OpenAI function format
|
|
159
|
+
# MCP format: { name:, description:, params: [...], return_type: }
|
|
160
|
+
# OpenAI format: { type: "function", function: { name:, description:, parameters: { type: "object", properties:, required: } } }
|
|
161
|
+
def convert_mcp_tool_to_openai_format(mcp_tool)
|
|
162
|
+
name = mcp_tool[:name] || mcp_tool["name"]
|
|
163
|
+
description = mcp_tool[:description] || mcp_tool["description"]
|
|
164
|
+
params = mcp_tool[:params] || mcp_tool["params"] || mcp_tool[:parameters] || mcp_tool["parameters"] || []
|
|
165
|
+
|
|
166
|
+
properties = {}
|
|
167
|
+
required = []
|
|
168
|
+
|
|
169
|
+
Array(params).each do |param|
|
|
170
|
+
param_name = (param[:name] || param["name"]).to_s
|
|
171
|
+
param_type = param[:type] || param["type"] || "string"
|
|
172
|
+
param_desc = param[:description] || param["description"]
|
|
173
|
+
param_required = param[:required] || param["required"]
|
|
174
|
+
|
|
175
|
+
# Convert Ruby/MCP types to JSON Schema types
|
|
176
|
+
json_type = case param_type.to_s.downcase
|
|
177
|
+
when "integer", "int" then "integer"
|
|
178
|
+
when "number", "float", "decimal" then "number"
|
|
179
|
+
when "boolean", "bool" then "boolean"
|
|
180
|
+
when "array" then "array"
|
|
181
|
+
when "object", "hash" then "object"
|
|
182
|
+
else "string"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
properties[param_name] = { type: json_type }
|
|
186
|
+
properties[param_name][:description] = param_desc if param_desc.present?
|
|
187
|
+
|
|
188
|
+
required << param_name if param_required
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
type: "function",
|
|
193
|
+
function: {
|
|
194
|
+
name: name,
|
|
195
|
+
description: description || "",
|
|
196
|
+
parameters: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: properties,
|
|
199
|
+
required: required
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_user_context
|
|
206
|
+
context_data = {}
|
|
207
|
+
|
|
208
|
+
# Extract IDs from context
|
|
209
|
+
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
210
|
+
comment_id = extract_id(@context, :comment) || @context[:comment_id]
|
|
211
|
+
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
212
|
+
|
|
213
|
+
# Create pending callback with nonce for secure async responses
|
|
214
|
+
callback = callback_url
|
|
215
|
+
if callback.present? && creative_id.present?
|
|
216
|
+
pending = PendingCallback.create_for_request(
|
|
217
|
+
user: @user,
|
|
218
|
+
creative_id: creative_id,
|
|
219
|
+
comment_id: comment_id,
|
|
220
|
+
thread_id: topic_id,
|
|
221
|
+
context: @context.slice(:extra_data).to_h
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
context_data[:callback_url] = callback
|
|
225
|
+
context_data[:callback_nonce] = pending.nonce
|
|
226
|
+
context_data[:creative_id] = creative_id
|
|
227
|
+
context_data[:comment_id] = comment_id if comment_id
|
|
228
|
+
context_data[:topic_id] = topic_id if topic_id
|
|
229
|
+
|
|
230
|
+
Rails.logger.info("[CollavreOpenclaw] Created pending callback with nonce: #{pending.nonce[0..8]}...")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Return as JSON string (OpenAI user field format)
|
|
234
|
+
if context_data.any?
|
|
235
|
+
"collavre:#{JSON.generate(context_data)}"
|
|
236
|
+
else
|
|
237
|
+
"collavre:#{@user.id}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Format messages with sender attribution for multi-user context
|
|
242
|
+
def format_messages(messages)
|
|
243
|
+
Array(messages).map do |msg|
|
|
244
|
+
role = msg[:role] || msg["role"]
|
|
245
|
+
parts = msg[:parts] || msg["parts"]
|
|
246
|
+
content = if parts
|
|
247
|
+
Array(parts).map { |p| p[:text] || p["text"] }.compact.join("\n")
|
|
248
|
+
else
|
|
249
|
+
msg[:text] || msg["text"] || msg[:content] || msg["content"]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Add sender attribution for user messages (multi-user support)
|
|
253
|
+
sender_name = msg[:sender_name] || msg["sender_name"]
|
|
254
|
+
if sender_name.present? && normalize_role(role) == "user"
|
|
255
|
+
content = "[#{sender_name}]: #{content}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
{ role: normalize_role(role), content: content.to_s }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def normalize_role(role)
|
|
263
|
+
case role.to_s
|
|
264
|
+
when "model", "assistant" then "assistant"
|
|
265
|
+
when "system" then "system"
|
|
266
|
+
else "user"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def build_headers
|
|
271
|
+
headers = {
|
|
272
|
+
"Content-Type" => "application/json",
|
|
273
|
+
"Accept" => "text/event-stream",
|
|
274
|
+
"x-openclaw-session-key" => session_key
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Add Authorization header if API key is configured
|
|
278
|
+
if @user&.llm_api_key.present?
|
|
279
|
+
headers["Authorization"] = "Bearer #{@user.llm_api_key}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
headers
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def stream_response(payload, &block)
|
|
286
|
+
retries = 0
|
|
287
|
+
max_retries = CollavreOpenclaw.config.max_retries
|
|
288
|
+
|
|
289
|
+
begin
|
|
290
|
+
connection = build_connection
|
|
291
|
+
buffer = +""
|
|
292
|
+
request_headers = build_headers
|
|
293
|
+
|
|
294
|
+
response = connection.post do |req|
|
|
295
|
+
req.url api_endpoint
|
|
296
|
+
request_headers.each { |k, v| req.headers[k] = v }
|
|
297
|
+
|
|
298
|
+
req.body = payload.to_json
|
|
299
|
+
|
|
300
|
+
req.options.on_data = proc do |chunk, _size, _env|
|
|
301
|
+
buffer << chunk
|
|
302
|
+
process_sse_buffer(buffer, &block)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Process any remaining data in buffer
|
|
307
|
+
process_sse_buffer(buffer, final: true, &block)
|
|
308
|
+
|
|
309
|
+
# Handle non-streaming response
|
|
310
|
+
if response.headers["content-type"]&.include?("application/json")
|
|
311
|
+
handle_json_response(response.body, &block)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
response
|
|
315
|
+
rescue Faraday::TimeoutError => e
|
|
316
|
+
retries += 1
|
|
317
|
+
if retries <= max_retries
|
|
318
|
+
Rails.logger.warn("[CollavreOpenclaw] Request timed out, retrying (#{retries}/#{max_retries})...")
|
|
319
|
+
sleep(1 * retries) # Exponential backoff
|
|
320
|
+
retry
|
|
321
|
+
end
|
|
322
|
+
raise "OpenClaw request timed out after #{max_retries + 1} attempts (read_timeout: #{CollavreOpenclaw.config.read_timeout}s)"
|
|
323
|
+
rescue Faraday::ConnectionFailed => e
|
|
324
|
+
retries += 1
|
|
325
|
+
if retries <= max_retries
|
|
326
|
+
Rails.logger.warn("[CollavreOpenclaw] Connection failed, retrying (#{retries}/#{max_retries})...")
|
|
327
|
+
sleep(1 * retries)
|
|
328
|
+
retry
|
|
329
|
+
end
|
|
330
|
+
raise "Failed to connect to OpenClaw after #{max_retries + 1} attempts: #{e.message}"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def handle_json_response(body, &block)
|
|
335
|
+
json = JSON.parse(body, symbolize_names: true)
|
|
336
|
+
content = json.dig(:choices, 0, :message, :content)
|
|
337
|
+
yield content if content.present? && block_given?
|
|
338
|
+
rescue JSON::ParserError
|
|
339
|
+
# Ignore
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def process_sse_buffer(buffer, final: false, &block)
|
|
343
|
+
while (idx = buffer.index("\n\n"))
|
|
344
|
+
event_data = buffer.slice!(0, idx + 2)
|
|
345
|
+
parse_sse_event(event_data, &block)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
if final && buffer.present?
|
|
349
|
+
parse_sse_event(buffer, &block)
|
|
350
|
+
buffer.clear
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def parse_sse_event(event_str, &block)
|
|
355
|
+
event_str.each_line do |line|
|
|
356
|
+
line = line.strip
|
|
357
|
+
next if line.empty? || line.start_with?(":")
|
|
358
|
+
|
|
359
|
+
if line.start_with?("data:")
|
|
360
|
+
data = line.sub(/^data:\s*/, "")
|
|
361
|
+
next if data == "[DONE]"
|
|
362
|
+
|
|
363
|
+
begin
|
|
364
|
+
json = JSON.parse(data, symbolize_names: true)
|
|
365
|
+
content = extract_content(json)
|
|
366
|
+
yield content if content.present?
|
|
367
|
+
rescue JSON::ParserError
|
|
368
|
+
yield data if data.present?
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def extract_content(json)
|
|
375
|
+
# OpenAI streaming format
|
|
376
|
+
json.dig(:choices, 0, :delta, :content) ||
|
|
377
|
+
json.dig(:choices, 0, :message, :content) ||
|
|
378
|
+
json[:content] ||
|
|
379
|
+
json[:text]
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def build_connection
|
|
383
|
+
Faraday.new do |builder|
|
|
384
|
+
builder.options.timeout = CollavreOpenclaw.config.read_timeout # Read timeout (3 min default)
|
|
385
|
+
builder.options.open_timeout = CollavreOpenclaw.config.open_timeout # Connection timeout (10s)
|
|
386
|
+
builder.adapter Faraday.default_adapter
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def extract_id(context, key)
|
|
391
|
+
value = context[key] || context[key.to_s]
|
|
392
|
+
return nil unless value
|
|
393
|
+
|
|
394
|
+
return value.id if value.respond_to?(:id)
|
|
395
|
+
value[:id] || value["id"]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Extract agent_id from user email
|
|
399
|
+
# e.g., "ai-agent@collavre.com" -> "ai-agent"
|
|
400
|
+
def extract_agent_id_from_email
|
|
401
|
+
return nil unless @user&.email.present?
|
|
402
|
+
|
|
403
|
+
# Extract local part (before @) from email
|
|
404
|
+
@user.email.split("@").first
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def default_url_options
|
|
408
|
+
options = Rails.application.config.action_mailer.default_url_options || {}
|
|
409
|
+
|
|
410
|
+
host = options[:host]
|
|
411
|
+
host ||= Rails.application.config.action_controller.default_url_options&.dig(:host)
|
|
412
|
+
host ||= ENV["APP_HOST"]
|
|
413
|
+
host ||= ENV["RAILS_HOST"]
|
|
414
|
+
|
|
415
|
+
result = { host: host }
|
|
416
|
+
result[:protocol] = options[:protocol] || "https"
|
|
417
|
+
result[:port] = options[:port] if options[:port].present?
|
|
418
|
+
|
|
419
|
+
result
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Rails.application.config.to_prepare do
|
|
2
|
+
if defined?(Collavre::AiClient)
|
|
3
|
+
# Prepend the extension if not already done
|
|
4
|
+
unless Collavre::AiClient.singleton_class.method_defined?(:register_adapter)
|
|
5
|
+
Collavre::AiClient.prepend(CollavreOpenclaw::AiClientExtension)
|
|
6
|
+
Rails.logger.info("[CollavreOpenclaw] Extended Collavre::AiClient with adapter support")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Register the OpenClaw adapter
|
|
10
|
+
unless Collavre::AiClient.adapter_registry.key?("openclaw")
|
|
11
|
+
Collavre::AiClient.register_adapter("openclaw", CollavreOpenclaw::OpenclawAdapter)
|
|
12
|
+
Rails.logger.info("[CollavreOpenclaw] Registered OpenClaw adapter")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreateOpenclawAccounts < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :openclaw_accounts do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: { to_table: :users }, index: { unique: true }
|
|
5
|
+
t.string :gateway_url, null: false
|
|
6
|
+
t.string :webhook_secret
|
|
7
|
+
t.string :api_token
|
|
8
|
+
t.string :channel_id
|
|
9
|
+
t.text :description
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreatePendingCallbacks < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :openclaw_pending_callbacks do |t|
|
|
4
|
+
t.references :openclaw_account, null: false, foreign_key: { to_table: :openclaw_accounts }
|
|
5
|
+
t.string :nonce, null: false, index: { unique: true }
|
|
6
|
+
t.integer :creative_id
|
|
7
|
+
t.integer :comment_id
|
|
8
|
+
t.integer :thread_id
|
|
9
|
+
t.text :context # Use text for SQLite compatibility, serialize in model
|
|
10
|
+
t.datetime :expires_at, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :openclaw_pending_callbacks, :expires_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module CollavreOpenclaw
|
|
2
|
+
class Configuration
|
|
3
|
+
# Connection timeout (seconds) - how long to wait for connection
|
|
4
|
+
attr_accessor :open_timeout
|
|
5
|
+
|
|
6
|
+
# Read timeout (seconds) - how long to wait for streaming response
|
|
7
|
+
# AI responses can take 60-180+ seconds with reasoning/tools
|
|
8
|
+
attr_accessor :read_timeout
|
|
9
|
+
|
|
10
|
+
# Max retries for transient failures
|
|
11
|
+
attr_accessor :max_retries
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
|
|
15
|
+
@read_timeout = ENV.fetch("OPENCLAW_READ_TIMEOUT", 180).to_i # 3 minutes for AI responses
|
|
16
|
+
@max_retries = ENV.fetch("OPENCLAW_MAX_RETRIES", 2).to_i
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Legacy accessor for backward compatibility
|
|
20
|
+
def request_timeout
|
|
21
|
+
@read_timeout
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def request_timeout=(value)
|
|
25
|
+
@read_timeout = value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module CollavreOpenclaw
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace CollavreOpenclaw
|
|
4
|
+
|
|
5
|
+
config.generators do |g|
|
|
6
|
+
g.test_framework :minitest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Path to engine's JavaScript sources for jsbundling-rails integration
|
|
10
|
+
def self.javascript_path
|
|
11
|
+
root.join("app/javascript")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Path to engine's stylesheet sources
|
|
15
|
+
def self.stylesheet_path
|
|
16
|
+
root.join("app/assets/stylesheets")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Load locale files
|
|
20
|
+
config.i18n.load_path += Dir[root.join("config", "locales", "*.yml")]
|
|
21
|
+
|
|
22
|
+
# Auto-mount engine routes
|
|
23
|
+
initializer "collavre_openclaw.routes", before: :add_routing_paths do |app|
|
|
24
|
+
app.routes.append do
|
|
25
|
+
mount CollavreOpenclaw::Engine => "/openclaw", as: :openclaw_engine
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Add engine stylesheets to asset paths for Propshaft
|
|
30
|
+
initializer "collavre_openclaw.assets" do |app|
|
|
31
|
+
if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:paths)
|
|
32
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
initializer "collavre_openclaw.migrations" do |app|
|
|
37
|
+
unless app.root.to_s.match?(root.to_s)
|
|
38
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
39
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "collavre_openclaw/version"
|
|
2
|
+
require "collavre_openclaw/configuration"
|
|
3
|
+
require "collavre_openclaw/engine"
|
|
4
|
+
|
|
5
|
+
module CollavreOpenclaw
|
|
6
|
+
def self.config
|
|
7
|
+
@config ||= Configuration.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.configure
|
|
11
|
+
yield(config)
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: collavre_openclaw
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Collavre
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
description: Enables AI agents in Collavre to use OpenClaw as their LLM backend
|
|
41
|
+
email:
|
|
42
|
+
- support@collavre.com
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- README.md
|
|
48
|
+
- Rakefile
|
|
49
|
+
- app/controllers/collavre_openclaw/application_controller.rb
|
|
50
|
+
- app/controllers/collavre_openclaw/callbacks_controller.rb
|
|
51
|
+
- app/controllers/collavre_openclaw/health_controller.rb
|
|
52
|
+
- app/jobs/collavre_openclaw/application_job.rb
|
|
53
|
+
- app/jobs/collavre_openclaw/callback_processor_job.rb
|
|
54
|
+
- app/models/collavre_openclaw/application_record.rb
|
|
55
|
+
- app/models/collavre_openclaw/pending_callback.rb
|
|
56
|
+
- app/services/collavre_openclaw/ai_client_extension.rb
|
|
57
|
+
- app/services/collavre_openclaw/openclaw_adapter.rb
|
|
58
|
+
- config/initializers/ai_client_extension.rb
|
|
59
|
+
- config/locales/en.yml
|
|
60
|
+
- config/locales/ko.yml
|
|
61
|
+
- config/routes.rb
|
|
62
|
+
- db/migrate/20260131000001_create_openclaw_accounts.rb
|
|
63
|
+
- db/migrate/20260201000001_create_pending_callbacks.rb
|
|
64
|
+
- db/migrate/20260202000001_remove_webhook_secret_from_openclaw_accounts.rb
|
|
65
|
+
- db/migrate/20260202074949_add_agent_id_to_openclaw_accounts.rb
|
|
66
|
+
- lib/collavre_openclaw.rb
|
|
67
|
+
- lib/collavre_openclaw/configuration.rb
|
|
68
|
+
- lib/collavre_openclaw/engine.rb
|
|
69
|
+
- lib/collavre_openclaw/version.rb
|
|
70
|
+
homepage: https://github.com/sh1nj1/plan42
|
|
71
|
+
licenses:
|
|
72
|
+
- AGPL-3.0
|
|
73
|
+
metadata:
|
|
74
|
+
homepage_uri: https://github.com/sh1nj1/plan42
|
|
75
|
+
source_code_uri: https://github.com/sh1nj1/plan42
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.6.7
|
|
91
|
+
specification_version: 4
|
|
92
|
+
summary: OpenClaw AI Gateway integration for Collavre
|
|
93
|
+
test_files: []
|