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
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,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,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
|