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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0cefe1bfe3e0bc5a5d810a0aea5543e468a6539ec26aa48515a1617340a1ea6b
4
+ data.tar.gz: 4c48629590b7b95ada9771d18c42bce1db9ea97a3532bc081e0ec3fc80115d16
5
+ SHA512:
6
+ metadata.gz: ab7224c85ec72ea2d7bed38257136495a0e6fc8b74a33c558373bd822bba5b1be92c594bbdbdb22a67836afc02a6e350bcd9923b31767a454d5af279424473de
7
+ data.tar.gz: 0d8adb020df29a332597afbe9964b19e77cb35d79744487c935fe759e6aa75593812f9a245d9c1b462ec0f7e19a1f90d6027f7ddde0997509952b543c915942d
data/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # Collavre OpenClaw Integration
2
+
3
+ This engine enables AI agents in Collavre to use [OpenClaw](https://github.com/openclaw/openclaw) as their LLM backend, with support for bidirectional communication.
4
+
5
+ ## Features
6
+
7
+ - **OpenAI-compatible API**: Uses OpenClaw's `/v1/chat/completions` endpoint
8
+ - **Streaming responses**: Real-time streaming of AI responses
9
+ - **Proactive messaging**: OpenClaw can send messages to Collavre without user prompt
10
+ - **Secure callbacks**: Nonce-based authentication for callback requests (no tokens exposed)
11
+ - **Topic-based sessions**: Each Topic gets its own OpenClaw session (isolated context)
12
+ - **Multi-user support**: Multiple users can share the same Topic context with sender attribution
13
+
14
+ ## Installation
15
+
16
+ The engine is automatically loaded when placed in the `engines/` directory of a Collavre installation.
17
+
18
+ ### Run Migrations
19
+
20
+ ```bash
21
+ bin/rails db:migrate
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ ### Environment Variables
27
+
28
+ - `OPENCLAW_READ_TIMEOUT` - Read timeout for streaming responses (default: 180 seconds)
29
+ - `OPENCLAW_OPEN_TIMEOUT` - Connection timeout (default: 10 seconds)
30
+ - `OPENCLAW_MAX_RETRIES` - Max retries for transient failures (default: 2)
31
+
32
+ ### Setting up an AI Agent with OpenClaw
33
+
34
+ 1. Create an AI agent user in Collavre
35
+ 2. Set the `llm_vendor` to `"openclaw"`
36
+ 3. Configure the OpenClaw account with:
37
+ - **Gateway URL**: The URL of your OpenClaw gateway
38
+ - **API Token**: (Optional) For authentication to OpenClaw
39
+ - **Channel ID**: (Optional) Specific channel to use
40
+
41
+ ## Session Mapping
42
+
43
+ Collavre's structure maps to OpenClaw sessions as follows:
44
+
45
+ ```
46
+ Collavre OpenClaw
47
+ ─────────────────────────────────────────────────────
48
+ Creative (Chat Room)
49
+ └── Topic A ──────────────────→ Session A
50
+ │ ├── [User1]: "Hello" (shared context)
51
+ │ ├── [User2]: "Hi there"
52
+ │ └── AI: "Hello everyone!"
53
+
54
+ └── Topic B ──────────────────→ Session B
55
+ └── [User1]: "New topic" (isolated context)
56
+ ```
57
+
58
+ **Key concepts:**
59
+ - **Topic = Session**: Each Topic gets its own OpenClaw session
60
+ - **Shared context**: All users in the same Topic share the conversation history
61
+ - **User attribution**: Messages include sender name: `[Username]: message`
62
+ - **Session key**: Stable key based on `account:creative:topic` (not nonce)
63
+
64
+ ### Session Key Format
65
+
66
+ ```
67
+ collavre:<account_id>:creative:<creative_id>:topic:<topic_id>
68
+ ```
69
+
70
+ This key is sent via `x-openclaw-session-key` header to ensure stable session routing.
71
+
72
+ ## How it Works
73
+
74
+ ### Request Flow (Collavre → OpenClaw)
75
+
76
+ ```
77
+ User mentions AI agent in Collavre
78
+
79
+ OpenclawAdapter builds request with context
80
+
81
+ Creates PendingCallback with secure nonce
82
+
83
+ Sends request to OpenClaw /v1/chat/completions
84
+
85
+ Streams response back to Collavre
86
+ ```
87
+
88
+ ### Callback Flow (OpenClaw → Collavre)
89
+
90
+ ```
91
+ OpenClaw wants to send proactive message
92
+
93
+ Extracts nonce from user context
94
+
95
+ POST /openclaw/callback/:account_id
96
+ { "nonce": "...", "type": "proactive", "content": "..." }
97
+
98
+ Collavre verifies nonce (one-time use)
99
+
100
+ Creates comment on the creative
101
+ ```
102
+
103
+ ## API Endpoints
104
+
105
+ ### `POST /openclaw/callback/:account_id`
106
+
107
+ Webhook endpoint for receiving messages from OpenClaw.
108
+
109
+ **Authentication Methods** (in priority order):
110
+
111
+ 1. **Nonce** (recommended, most secure):
112
+ ```json
113
+ {
114
+ "nonce": "abc123...",
115
+ "type": "proactive",
116
+ "content": "Hello from OpenClaw!"
117
+ }
118
+ ```
119
+ - Nonce is provided in the `user` field when Collavre sends requests
120
+ - One-time use, expires after 10 minutes
121
+ - No token exposure
122
+
123
+ 2. **HMAC Signature**:
124
+ ```
125
+ X-OpenClaw-Signature: <hmac-sha256-hex>
126
+ ```
127
+ - Signature = HMAC-SHA256(api_token, request_body)
128
+
129
+ 3. **Bearer Token**:
130
+ ```
131
+ Authorization: Bearer <api_token>
132
+ ```
133
+
134
+ ### Payload Types
135
+
136
+ **Proactive Message** (OpenClaw initiates):
137
+ ```json
138
+ {
139
+ "type": "proactive",
140
+ "nonce": "callback_nonce_from_user_context",
141
+ "content": "This is a proactive message from AI!",
142
+ "creative_id": 123, // Optional if using nonce
143
+ "thread_id": 456 // Optional
144
+ }
145
+ ```
146
+
147
+ **Response** (async response to request):
148
+ ```json
149
+ {
150
+ "type": "response",
151
+ "nonce": "...",
152
+ "content": "AI response content",
153
+ "context": {
154
+ "comment_id": 789 // Updates existing comment
155
+ }
156
+ }
157
+ ```
158
+
159
+ **Error**:
160
+ ```json
161
+ {
162
+ "type": "error",
163
+ "nonce": "...",
164
+ "error": "Rate limit exceeded"
165
+ }
166
+ ```
167
+
168
+ ### `GET /openclaw/health`
169
+
170
+ Health check endpoint.
171
+
172
+ ## Security
173
+
174
+ ### Nonce-based Authentication
175
+
176
+ When Collavre sends a request to OpenClaw, it includes callback information in the `user` field:
177
+
178
+ ```json
179
+ {
180
+ "model": "openclaw",
181
+ "messages": [...],
182
+ "user": "collavre:{\"callback_url\":\"https://collavre.com/openclaw/callback/1\",\"callback_nonce\":\"abc123...\",\"creative_id\":456}"
183
+ }
184
+ ```
185
+
186
+ The nonce:
187
+ - Is unique per request
188
+ - Expires after 10 minutes
189
+ - Can only be used once (consumed on verification)
190
+ - Is tied to a specific account
191
+
192
+ This means:
193
+ - **No tokens are transmitted** in the callback request
194
+ - **Replay attacks are prevented** (nonce is consumed)
195
+ - **Expired requests are rejected**
196
+ - **Cross-account attacks are blocked** (nonce is bound to account)
197
+
198
+ ### Cleanup
199
+
200
+ Expired pending callbacks are automatically cleaned up. You can also run:
201
+
202
+ ```ruby
203
+ CollavreOpenclaw::PendingCallback.cleanup_expired!
204
+ ```
205
+
206
+ ## OpenAI API Compatibility
207
+
208
+ OpenClaw Gateway provides an **OpenAI-compatible Chat Completions endpoint** at `/v1/chat/completions`.
209
+
210
+ ### Enabling the Endpoint
211
+
212
+ In your OpenClaw Gateway config (`openclaw.config.json5`):
213
+
214
+ ```json5
215
+ {
216
+ gateway: {
217
+ http: {
218
+ endpoints: {
219
+ chatCompletions: { enabled: true }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ ### Authentication
227
+
228
+ Send a bearer token:
229
+ ```
230
+ Authorization: Bearer <token>
231
+ ```
232
+
233
+ ### Example: Non-streaming Request
234
+
235
+ ```bash
236
+ curl -sS http://127.0.0.1:18789/v1/chat/completions \
237
+ -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
238
+ -H 'Content-Type: application/json' \
239
+ -d '{
240
+ "model": "openclaw:main",
241
+ "messages": [{"role":"user","content":"Hello!"}]
242
+ }'
243
+ ```
244
+
245
+ ### Example: Streaming Request (SSE)
246
+
247
+ ```bash
248
+ curl -N http://127.0.0.1:18789/v1/chat/completions \
249
+ -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
250
+ -H 'Content-Type: application/json' \
251
+ -d '{
252
+ "model": "openclaw:main",
253
+ "stream": true,
254
+ "messages": [{"role":"user","content":"Hello!"}]
255
+ }'
256
+ ```
257
+
258
+ ## OpenClaw Callback (For OpenClaw Users)
259
+
260
+ When OpenClaw receives a request from Collavre, the `user` field contains callback information:
261
+
262
+ ```
263
+ collavre:{"callback_url":"https://...","callback_nonce":"...","creative_id":123}
264
+ ```
265
+
266
+ To send a proactive message back:
267
+
268
+ ```bash
269
+ # Parse the user field to extract callback info
270
+ CALLBACK_URL="https://collavre.com/openclaw/callback/1"
271
+ NONCE="abc123..."
272
+
273
+ # Send proactive message (no auth header needed - nonce is the auth)
274
+ curl -X POST "$CALLBACK_URL" \
275
+ -H "Content-Type: application/json" \
276
+ -d '{
277
+ "type": "proactive",
278
+ "nonce": "'"$NONCE"'",
279
+ "content": "Hello from OpenClaw!"
280
+ }'
281
+ ```
282
+
283
+ ## License
284
+
285
+ AGPL-3.0, same as Collavre.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
5
+
6
+ desc "Run engine tests"
7
+ task :test do
8
+ Dir.glob("test/**/*_test.rb").each { |f| require_relative f }
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,22 @@
1
+ module CollavreOpenclaw
2
+ class ApplicationController < ::ApplicationController
3
+ protect_from_forgery with: :exception
4
+
5
+ private
6
+
7
+ # Helper to access this engine's routes
8
+ def collavre_openclaw
9
+ CollavreOpenclaw::Engine.routes.url_helpers
10
+ end
11
+
12
+ # Helper to access Collavre engine routes
13
+ def collavre
14
+ Collavre::Engine.routes.url_helpers
15
+ end
16
+
17
+ # Helper to access main app routes
18
+ def main_app
19
+ Rails.application.routes.url_helpers
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,78 @@
1
+ module CollavreOpenclaw
2
+ class CallbacksController < ApplicationController
3
+ skip_forgery_protection only: :create
4
+ allow_unauthenticated_access only: :create
5
+
6
+ # Handle JSON parsing errors at Rails level
7
+ rescue_from ActionDispatch::Http::Parameters::ParseError do |_exception|
8
+ render json: { error: "Invalid JSON" }, status: :bad_request
9
+ end
10
+
11
+ def create
12
+ user = User.find_by(id: params[:user_id])
13
+
14
+ unless user
15
+ render json: { error: "User not found" }, status: :not_found
16
+ return
17
+ end
18
+
19
+ # Parse payload first
20
+ begin
21
+ payload = JSON.parse(request.raw_post, symbolize_names: true)
22
+ rescue JSON::ParserError
23
+ render json: { error: "Invalid JSON" }, status: :bad_request
24
+ return
25
+ end
26
+
27
+ # Authenticate the request via nonce
28
+ auth_result = authenticate_request(user, payload)
29
+ unless auth_result[:success]
30
+ render json: { error: auth_result[:error] }, status: :unauthorized
31
+ return
32
+ end
33
+
34
+ # Merge context from pending callback if nonce was used
35
+ if auth_result[:pending_callback]
36
+ payload = merge_pending_callback_context(payload, auth_result[:pending_callback])
37
+ end
38
+
39
+ # Process the callback payload
40
+ CallbackProcessorJob.perform_later(user.id, payload.deep_stringify_keys)
41
+
42
+ head :ok
43
+ end
44
+
45
+ private
46
+
47
+ # Authenticate using nonce verification
48
+ def authenticate_request(user, payload)
49
+ # Nonce verification (required for callbacks)
50
+ nonce = payload[:nonce] || payload[:callback_nonce]
51
+ if nonce.present?
52
+ pending = PendingCallback.verify_and_consume!(nonce)
53
+ if pending && pending.user_id == user.id
54
+ return { success: true, pending_callback: pending }
55
+ else
56
+ return { success: false, error: "Invalid or expired nonce" }
57
+ end
58
+ end
59
+
60
+ { success: false, error: "Nonce required for callback authentication" }
61
+ end
62
+
63
+ def merge_pending_callback_context(payload, pending)
64
+ # Add context from pending callback
65
+ payload[:context] ||= {}
66
+ payload[:context][:creative_id] ||= pending.creative_id
67
+ payload[:context][:comment_id] ||= pending.comment_id
68
+ payload[:context][:thread_id] ||= pending.thread_id
69
+
70
+ # Merge any extra context stored in pending callback
71
+ if pending.context.present?
72
+ payload[:context].merge!(pending.context.deep_symbolize_keys)
73
+ end
74
+
75
+ payload
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,13 @@
1
+ module CollavreOpenclaw
2
+ class HealthController < ApplicationController
3
+ allow_unauthenticated_access only: :show
4
+
5
+ def show
6
+ render json: {
7
+ status: "ok",
8
+ engine: "collavre_openclaw",
9
+ version: CollavreOpenclaw::VERSION
10
+ }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ module CollavreOpenclaw
2
+ class ApplicationJob < ActiveJob::Base
3
+ queue_as :default
4
+ end
5
+ end
@@ -0,0 +1,124 @@
1
+ module CollavreOpenclaw
2
+ class CallbackProcessorJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(user_id, payload)
6
+ @user = User.find_by(id: user_id)
7
+ return unless @user
8
+
9
+ # Symbolize keys for consistent access
10
+ payload = payload.deep_symbolize_keys
11
+
12
+ Rails.logger.info("[CollavreOpenclaw] Processing callback for user #{user_id}, type: #{payload[:type]}")
13
+
14
+ case payload[:type]&.to_s
15
+ when "response"
16
+ handle_response(payload)
17
+ when "proactive"
18
+ handle_proactive(payload)
19
+ when "error"
20
+ handle_error(payload)
21
+ else
22
+ # Default: try to handle as response for backward compatibility
23
+ if payload[:content].present? || payload[:message].present?
24
+ handle_response(payload)
25
+ else
26
+ Rails.logger.warn("[CollavreOpenclaw] Unknown callback type: #{payload[:type]}")
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Handle async response to a previous request
34
+ def handle_response(payload)
35
+ context = payload[:context] || {}
36
+ creative_id = context[:creative_id]
37
+ comment_id = context[:comment_id]
38
+ content = payload[:content] || payload[:message]
39
+
40
+ if comment_id.present?
41
+ # Update existing comment (streaming completion)
42
+ comment = Collavre::Comment.find_by(id: comment_id)
43
+ if comment && content.present?
44
+ comment.update!(content: content)
45
+ Rails.logger.info("[CollavreOpenclaw] Updated comment #{comment_id} with callback response")
46
+ end
47
+ elsif creative_id.present? && content.present?
48
+ # Create new comment on the creative
49
+ create_ai_comment(creative_id, content, context)
50
+ end
51
+ end
52
+
53
+ # Handle proactive message from OpenClaw (agent-initiated)
54
+ def handle_proactive(payload)
55
+ creative_id = payload[:creative_id] || payload.dig(:context, :creative_id)
56
+ content = payload[:content] || payload[:message]
57
+ thread_id = payload[:thread_id] || payload.dig(:context, :thread_id)
58
+ parent_comment_id = payload[:parent_comment_id] || payload.dig(:context, :parent_comment_id)
59
+
60
+ unless creative_id.present?
61
+ Rails.logger.error("[CollavreOpenclaw] Proactive message missing creative_id")
62
+ return
63
+ end
64
+
65
+ unless content.present?
66
+ Rails.logger.error("[CollavreOpenclaw] Proactive message missing content")
67
+ return
68
+ end
69
+
70
+ context = {
71
+ thread_id: thread_id,
72
+ parent_comment_id: parent_comment_id,
73
+ proactive: true
74
+ }
75
+
76
+ create_ai_comment(creative_id, content, context)
77
+ end
78
+
79
+ def handle_error(payload)
80
+ error_message = payload[:error] || payload[:message] || "Unknown error"
81
+ Rails.logger.error("[CollavreOpenclaw] Callback error: #{error_message}")
82
+
83
+ # Optionally notify the creative if context is available
84
+ creative_id = payload.dig(:context, :creative_id)
85
+ if creative_id.present?
86
+ create_ai_comment(
87
+ creative_id,
88
+ "⚠️ OpenClaw Error: #{error_message}",
89
+ { error: true }
90
+ )
91
+ end
92
+ end
93
+
94
+ def create_ai_comment(creative_id, content, context = {})
95
+ creative = Collavre::Creative.find_by(id: creative_id)
96
+ unless creative
97
+ Rails.logger.error("[CollavreOpenclaw] Creative not found: #{creative_id}")
98
+ return
99
+ end
100
+
101
+ # Build comment attributes
102
+ comment_attrs = {
103
+ creative: creative.effective_origin,
104
+ user: @user,
105
+ content: content,
106
+ private: false
107
+ }
108
+
109
+ # Handle topic/thread if specified
110
+ if context[:thread_id].present?
111
+ topic = Collavre::Topic.find_by(id: context[:thread_id])
112
+ comment_attrs[:topic] = topic if topic
113
+ end
114
+
115
+ comment = Collavre::Comment.create!(comment_attrs)
116
+ Rails.logger.info("[CollavreOpenclaw] Created AI comment #{comment.id} on creative #{creative_id}")
117
+
118
+ comment
119
+ rescue ActiveRecord::RecordInvalid => e
120
+ Rails.logger.error("[CollavreOpenclaw] Failed to create comment: #{e.message}")
121
+ nil
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ module CollavreOpenclaw
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,53 @@
1
+ module CollavreOpenclaw
2
+ class PendingCallback < ApplicationRecord
3
+ self.table_name = "openclaw_pending_callbacks"
4
+
5
+ belongs_to :user, class_name: "::User"
6
+
7
+ # Serialize context as JSON for SQLite compatibility
8
+ serialize :context, coder: JSON
9
+
10
+ validates :nonce, presence: true, uniqueness: true
11
+ validates :expires_at, presence: true
12
+
13
+ scope :valid, -> { where("expires_at > ?", Time.current) }
14
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
15
+
16
+ # Default expiration time (7 days to support scheduled callbacks like cron jobs)
17
+ EXPIRATION_TIME = 7.days
18
+
19
+ # Generate a new pending callback for a request
20
+ def self.create_for_request(user:, creative_id: nil, comment_id: nil, thread_id: nil, context: {})
21
+ create!(
22
+ user: user,
23
+ nonce: generate_nonce,
24
+ creative_id: creative_id,
25
+ comment_id: comment_id,
26
+ thread_id: thread_id,
27
+ context: context || {},
28
+ expires_at: EXPIRATION_TIME.from_now
29
+ )
30
+ end
31
+
32
+ # Verify and consume a nonce (one-time use)
33
+ def self.verify_and_consume!(nonce)
34
+ callback = valid.find_by(nonce: nonce)
35
+ return nil unless callback
36
+
37
+ # Consume the nonce (delete it)
38
+ callback.destroy!
39
+ callback
40
+ end
41
+
42
+ # Cleanup expired callbacks
43
+ def self.cleanup_expired!
44
+ expired.delete_all
45
+ end
46
+
47
+ private
48
+
49
+ def self.generate_nonce
50
+ SecureRandom.urlsafe_base64(32)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,61 @@
1
+ module CollavreOpenclaw
2
+ module AiClientExtension
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def adapter_registry
7
+ @adapter_registry ||= {}
8
+ end
9
+
10
+ def register_adapter(vendor, adapter_class)
11
+ adapter_registry[vendor.to_s.downcase] = adapter_class
12
+ end
13
+ end
14
+
15
+ def chat(contents, tools: [], &block)
16
+ normalized_vendor = vendor.to_s.downcase
17
+
18
+ # Check if we have a custom adapter for this vendor
19
+ adapter_class = self.class.adapter_registry[normalized_vendor]
20
+
21
+ if adapter_class
22
+ # Use the custom adapter
23
+ user = context&.dig(:user)
24
+ adapter = adapter_class.new(
25
+ user: user,
26
+ system_prompt: system_prompt,
27
+ context: context
28
+ )
29
+
30
+ response_content = nil
31
+ error_message = nil
32
+
33
+ begin
34
+ response_content = adapter.chat(contents, tools: tools, &block)
35
+ rescue StandardError => e
36
+ error_message = e.message
37
+ raise
38
+ ensure
39
+ # Log the interaction just like AiClient does
40
+ log_interaction(
41
+ messages: Array(contents),
42
+ tools: tools,
43
+ response_content: response_content,
44
+ error_message: error_message,
45
+ input_tokens: nil,
46
+ output_tokens: nil
47
+ )
48
+ end
49
+
50
+ return response_content
51
+ end
52
+
53
+ # Fall back to original implementation
54
+ super
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :vendor, :system_prompt, :context
60
+ end
61
+ end