@heyamiko/openclaw-plugin 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.
Files changed (55) hide show
  1. package/README.md +200 -0
  2. package/contracts/channel-config.schema.json +87 -0
  3. package/contracts/platform-ack.schema.json +25 -0
  4. package/contracts/platform-events.schema.json +87 -0
  5. package/contracts/platform-outbound.schema.json +47 -0
  6. package/dist/index.d.ts +20 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +61 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/src/accounts.d.ts +30 -0
  11. package/dist/src/accounts.d.ts.map +1 -0
  12. package/dist/src/accounts.js +115 -0
  13. package/dist/src/accounts.js.map +1 -0
  14. package/dist/src/api.d.ts +13 -0
  15. package/dist/src/api.d.ts.map +1 -0
  16. package/dist/src/api.js +45 -0
  17. package/dist/src/api.js.map +1 -0
  18. package/dist/src/channel.d.ts +174 -0
  19. package/dist/src/channel.d.ts.map +1 -0
  20. package/dist/src/channel.js +140 -0
  21. package/dist/src/channel.js.map +1 -0
  22. package/dist/src/config-schema.d.ts +92 -0
  23. package/dist/src/config-schema.d.ts.map +1 -0
  24. package/dist/src/config-schema.js +17 -0
  25. package/dist/src/config-schema.js.map +1 -0
  26. package/dist/src/monitor.d.ts +19 -0
  27. package/dist/src/monitor.d.ts.map +1 -0
  28. package/dist/src/monitor.js +432 -0
  29. package/dist/src/monitor.js.map +1 -0
  30. package/dist/src/reply-prefix.d.ts +12 -0
  31. package/dist/src/reply-prefix.d.ts.map +1 -0
  32. package/dist/src/reply-prefix.js +8 -0
  33. package/dist/src/reply-prefix.js.map +1 -0
  34. package/dist/src/runtime.d.ts +57 -0
  35. package/dist/src/runtime.d.ts.map +1 -0
  36. package/dist/src/runtime.js +28 -0
  37. package/dist/src/runtime.js.map +1 -0
  38. package/dist/src/send.d.ts +8 -0
  39. package/dist/src/send.d.ts.map +1 -0
  40. package/dist/src/send.js +51 -0
  41. package/dist/src/send.js.map +1 -0
  42. package/dist/src/status.d.ts +19 -0
  43. package/dist/src/status.d.ts.map +1 -0
  44. package/dist/src/status.js +51 -0
  45. package/dist/src/status.js.map +1 -0
  46. package/dist/src/types.d.ts +79 -0
  47. package/dist/src/types.d.ts.map +1 -0
  48. package/dist/src/types.js +2 -0
  49. package/dist/src/types.js.map +1 -0
  50. package/openclaw.plugin.json +51 -0
  51. package/package.json +73 -0
  52. package/skills/amiko/SKILL.md +287 -0
  53. package/skills/amiko/cli.js +521 -0
  54. package/skills/amiko/lib.js +634 -0
  55. package/skills/composio/SKILL.md +102 -0
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Amiko Platform API Library
3
+ * Reads config from:
4
+ * 1. OPENCLAW_CONFIG_PATH
5
+ * 2. OPENCLAW_STATE_DIR/openclaw.json
6
+ * 3. /data/.openclaw/openclaw.json
7
+ * 4. ~/.openclaw/openclaw.json
8
+ * Uses channels.amiko from that config.
9
+ * Uses the twin token already configured for the OpenClaw plugin.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ const DEFAULT_PLATFORM_API_BASE_URL = "https://platform.heyamiko.com";
16
+ const DEFAULT_CHAT_API_BASE_URL = "https://api.amiko.app";
17
+
18
+ let _accountId = "";
19
+
20
+ export function setAccountId(accountId) {
21
+ _accountId = (accountId || "").trim();
22
+ }
23
+
24
+ export function resolveConfigPath() {
25
+ if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
26
+ if (process.env.OPENCLAW_STATE_DIR) {
27
+ return path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json");
28
+ }
29
+ const dataPath = "/data/.openclaw/openclaw.json";
30
+ if (fs.existsSync(dataPath)) return dataPath;
31
+ const homeDir = process.env.HOME || "/root";
32
+ return path.join(homeDir, ".openclaw", "openclaw.json");
33
+ }
34
+
35
+ function stripJsonComments(text) {
36
+ return text
37
+ .replace(/\/\/.*$/gm, "")
38
+ .replace(/\/\*[\s\S]*?\*\//g, "")
39
+ .replace(/,(\s*[}\]])/g, "$1");
40
+ }
41
+
42
+ function normalizeApiBaseUrl(value, fallback = DEFAULT_PLATFORM_API_BASE_URL) {
43
+ const raw = String(value || "").trim() || fallback;
44
+ return raw.replace(/\/+$/, "").replace(/\/api$/, "");
45
+ }
46
+
47
+ function detectAgentIdFromCwd() {
48
+ const cwd = process.cwd();
49
+ const match = cwd.match(/(?:^|[\\/])workspace(?:-([^\\/]+))?(?:[\\/]|$)/);
50
+ if (!match) return "";
51
+ return match[1] ? match[1].trim().toLowerCase() : "main";
52
+ }
53
+
54
+ export function detectCurrentAccountId() {
55
+ const fromEnv = String(process.env.OPENCLAW_AGENT_ID || "").trim().toLowerCase();
56
+ if (fromEnv) return fromEnv;
57
+ return detectAgentIdFromCwd();
58
+ }
59
+
60
+ function loadOpenClawConfig() {
61
+ const configPath = resolveConfigPath();
62
+
63
+ if (!fs.existsSync(configPath)) {
64
+ throw new Error(`OpenClaw config not found: ${configPath}`);
65
+ }
66
+
67
+ try {
68
+ const raw = fs.readFileSync(configPath, "utf8");
69
+ return JSON.parse(stripJsonComments(raw));
70
+ } catch (error) {
71
+ throw new Error(`Failed to parse OpenClaw config: ${error.message}`);
72
+ }
73
+ }
74
+
75
+ export function listConfiguredAccounts() {
76
+ const raw = loadOpenClawConfig();
77
+ const amiko = raw?.channels?.amiko;
78
+
79
+ if (!amiko) return [];
80
+ if (amiko.accounts && Object.keys(amiko.accounts).length > 0) {
81
+ return Object.keys(amiko.accounts)
82
+ .map((accountId) => accountId.trim().toLowerCase())
83
+ .sort();
84
+ }
85
+
86
+ const singleId =
87
+ amiko.agentId ||
88
+ amiko.twinId ||
89
+ amiko.accountId ||
90
+ amiko.id ||
91
+ _accountId;
92
+
93
+ return singleId ? [singleId] : [];
94
+ }
95
+
96
+ function resolveSingleAccountConfig(amiko, requestedAccountId) {
97
+ return {
98
+ accountId:
99
+ requestedAccountId ||
100
+ amiko.agentId ||
101
+ amiko.twinId ||
102
+ amiko.accountId ||
103
+ amiko.id ||
104
+ "",
105
+ twinId: amiko.twinId || amiko.accountId || amiko.id || "",
106
+ token: amiko.token || "",
107
+ platformApiBaseUrl: normalizeApiBaseUrl(
108
+ amiko.platformApiBaseUrl || amiko.apiBaseUrl,
109
+ DEFAULT_PLATFORM_API_BASE_URL,
110
+ ),
111
+ chatApiBaseUrl: normalizeApiBaseUrl(
112
+ amiko.chatApiBaseUrl || amiko.apiBaseUrl || amiko.platformApiBaseUrl,
113
+ DEFAULT_CHAT_API_BASE_URL,
114
+ ),
115
+ };
116
+ }
117
+
118
+ function resolveMultiAccountConfig(amiko, requestedAccountId) {
119
+ const accountMap = Object.fromEntries(
120
+ Object.entries(amiko.accounts || {}).map(([accountId, config]) => [
121
+ accountId.trim().toLowerCase(),
122
+ config,
123
+ ]),
124
+ );
125
+ const accountIds = Object.keys(accountMap);
126
+ if (accountIds.length === 0) {
127
+ throw new Error("No channels.amiko.accounts configured");
128
+ }
129
+
130
+ const requested = String(
131
+ requestedAccountId || detectCurrentAccountId() || amiko.defaultAccount || "",
132
+ )
133
+ .trim()
134
+ .toLowerCase();
135
+ const fallbackAccountId = accountIds.includes("main") ? "main" : accountIds[0];
136
+ const normalizedResolvedAccountId = accountIds.includes(requested)
137
+ ? requested
138
+ : fallbackAccountId;
139
+ const accountConfig = accountMap[normalizedResolvedAccountId];
140
+
141
+ if (!accountConfig) {
142
+ throw new Error(
143
+ `Amiko account "${normalizedResolvedAccountId}" not found. Available accounts: ${accountIds.join(", ")}`,
144
+ );
145
+ }
146
+
147
+ return {
148
+ accountId: normalizedResolvedAccountId,
149
+ twinId: accountConfig.twinId || amiko.twinId || "",
150
+ token: accountConfig.token || amiko.token || "",
151
+ platformApiBaseUrl: normalizeApiBaseUrl(
152
+ accountConfig.platformApiBaseUrl ||
153
+ accountConfig.apiBaseUrl ||
154
+ amiko.platformApiBaseUrl ||
155
+ amiko.apiBaseUrl,
156
+ DEFAULT_PLATFORM_API_BASE_URL,
157
+ ),
158
+ chatApiBaseUrl: normalizeApiBaseUrl(
159
+ accountConfig.chatApiBaseUrl ||
160
+ accountConfig.apiBaseUrl ||
161
+ amiko.chatApiBaseUrl ||
162
+ amiko.apiBaseUrl ||
163
+ amiko.platformApiBaseUrl,
164
+ DEFAULT_CHAT_API_BASE_URL,
165
+ ),
166
+ };
167
+ }
168
+
169
+ function loadConfig(accountId) {
170
+ const raw = loadOpenClawConfig();
171
+ const amiko = raw?.channels?.amiko;
172
+
173
+ if (!amiko) {
174
+ throw new Error("No channels.amiko section found in openclaw.json");
175
+ }
176
+
177
+ if (amiko.accounts) {
178
+ return resolveMultiAccountConfig(amiko, accountId);
179
+ }
180
+
181
+ return resolveSingleAccountConfig(amiko, accountId);
182
+ }
183
+
184
+ export function getConfig() {
185
+ const config = loadConfig(_accountId || detectCurrentAccountId() || undefined);
186
+
187
+ if (!config.twinId) {
188
+ throw new Error(
189
+ "No twinId configured. Use channels.amiko.accounts.<agentId>.twinId in openclaw.json.",
190
+ );
191
+ }
192
+
193
+ if (!config.token) {
194
+ throw new Error(
195
+ "No token configured for channels.amiko. Add a twin token in openclaw.json.",
196
+ );
197
+ }
198
+
199
+ return {
200
+ twinId: config.twinId,
201
+ accountId: config.accountId,
202
+ token: config.token,
203
+ platformApiBaseUrl: config.platformApiBaseUrl,
204
+ chatApiBaseUrl: config.chatApiBaseUrl,
205
+ };
206
+ }
207
+
208
+ function ensureLeadingSlash(value) {
209
+ return value.startsWith("/") ? value : `/${value}`;
210
+ }
211
+
212
+ async function readResponseError(response) {
213
+ const text = await response.text().catch(() => "");
214
+ return text || response.statusText || "Unknown error";
215
+ }
216
+
217
+ async function expectJson(response, label) {
218
+ if (!response.ok) {
219
+ throw new Error(
220
+ `${label} failed: ${response.status} - ${await readResponseError(response)}`,
221
+ );
222
+ }
223
+ return response.json();
224
+ }
225
+
226
+ async function requestJson(endpoint, options, label) {
227
+ const response = await apiRequest(endpoint, options);
228
+ return expectJson(response, label);
229
+ }
230
+
231
+ function createJsonRequest(body) {
232
+ return {
233
+ method: "POST",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify(body),
236
+ };
237
+ }
238
+
239
+ function getMimeType(ext) {
240
+ const types = {
241
+ ".txt": "text/plain",
242
+ ".md": "text/markdown",
243
+ ".html": "text/html",
244
+ ".json": "application/json",
245
+ ".csv": "text/csv",
246
+ ".pdf": "application/pdf",
247
+ ".doc": "application/msword",
248
+ ".docx":
249
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
250
+ ".png": "image/png",
251
+ ".jpg": "image/jpeg",
252
+ ".jpeg": "image/jpeg",
253
+ ".gif": "image/gif",
254
+ ".webp": "image/webp",
255
+ ".mp3": "audio/mpeg",
256
+ ".wav": "audio/wav",
257
+ ".m4a": "audio/mp4",
258
+ ".mp4": "video/mp4",
259
+ };
260
+ const normalized = ext.startsWith(".") ? ext : `.${ext}`;
261
+ return types[normalized.toLowerCase()] || "application/octet-stream";
262
+ }
263
+
264
+ function createBlobFromFileInput(file, options = {}) {
265
+ if (typeof file === "string") {
266
+ const buffer = fs.readFileSync(file);
267
+ const filename = options.filename || path.basename(file);
268
+ return {
269
+ blob: new Blob([buffer], {
270
+ type: options.contentType || getMimeType(path.extname(filename)),
271
+ }),
272
+ filename,
273
+ };
274
+ }
275
+
276
+ if (Buffer.isBuffer(file)) {
277
+ if (!options.filename) {
278
+ throw new Error("filename is required when file is a Buffer");
279
+ }
280
+ return {
281
+ blob: new Blob([file], {
282
+ type: options.contentType || getMimeType(path.extname(options.filename)),
283
+ }),
284
+ filename: options.filename,
285
+ };
286
+ }
287
+
288
+ throw new Error("File must be a file path or Buffer");
289
+ }
290
+
291
+ export async function apiRequest(endpoint, options = {}) {
292
+ const config = getConfig();
293
+ const url = `${config.platformApiBaseUrl}${ensureLeadingSlash(endpoint)}`;
294
+ const headers = new Headers(options.headers || {});
295
+
296
+ if (!headers.has("Authorization")) {
297
+ headers.set("Authorization", `Bearer ${config.token}`);
298
+ }
299
+ if (!headers.has("Accept")) {
300
+ headers.set("Accept", "application/json");
301
+ }
302
+
303
+ return fetch(url, {
304
+ ...options,
305
+ headers,
306
+ });
307
+ }
308
+
309
+ export async function getTwinInfo() {
310
+ return requestJson(`/api/agents/${getConfig().twinId}`, {}, "Get twin info");
311
+ }
312
+
313
+ export async function updateTwin(data) {
314
+ return requestJson(
315
+ `/api/agents/${getConfig().twinId}`,
316
+ {
317
+ method: "PATCH",
318
+ headers: { "Content-Type": "application/json" },
319
+ body: JSON.stringify(data),
320
+ },
321
+ "Update twin",
322
+ );
323
+ }
324
+
325
+ export async function listDocs(options = {}) {
326
+ const params = new URLSearchParams();
327
+ params.set("limit", String(options.limit ?? 50));
328
+ params.set("offset", String(options.offset ?? 0));
329
+ if (options.search) params.set("search", options.search);
330
+
331
+ return requestJson(
332
+ `/api/agents/${getConfig().twinId}/docs?${params.toString()}`,
333
+ {},
334
+ "List docs",
335
+ );
336
+ }
337
+
338
+ export async function getDoc(docId) {
339
+ if (!docId) throw new Error("docId is required");
340
+ return requestJson(
341
+ `/api/agents/${getConfig().twinId}/docs/${docId}`,
342
+ {},
343
+ "Get doc",
344
+ );
345
+ }
346
+
347
+ export async function createDoc(docData) {
348
+ return requestJson(
349
+ `/api/agents/${getConfig().twinId}/docs`,
350
+ createJsonRequest(docData),
351
+ "Create doc",
352
+ );
353
+ }
354
+
355
+ export async function createDocUploadUrl(filename, contentType) {
356
+ if (!filename) throw new Error("filename is required");
357
+ return requestJson(
358
+ `/api/agents/${getConfig().twinId}/docs/presigned-url`,
359
+ createJsonRequest({ filename, contentType }),
360
+ "Create doc upload URL",
361
+ );
362
+ }
363
+
364
+ export async function uploadDocFile(file, options = {}) {
365
+ const { blob, filename } = createBlobFromFileInput(file, options);
366
+ const formData = new FormData();
367
+ formData.append("file", blob, filename);
368
+
369
+ return requestJson(
370
+ `/api/agents/${getConfig().twinId}/docs/upload`,
371
+ { method: "POST", body: formData },
372
+ "Upload doc file",
373
+ );
374
+ }
375
+
376
+ export async function uploadDoc(file, options = {}) {
377
+ const uploadResult = await uploadDocFile(file, options);
378
+ const filename = options.filename || uploadResult.filename;
379
+ const title = options.title || filename;
380
+
381
+ return createDoc({
382
+ filename,
383
+ fileType: options.contentType || uploadResult.fileType,
384
+ fileUrl: uploadResult.url,
385
+ title,
386
+ description: options.description,
387
+ doc_type: options.docType || "other",
388
+ relationship: options.relationship ?? null,
389
+ stance: options.stance ?? null,
390
+ });
391
+ }
392
+
393
+ export async function uploadDocFromFile(filePath, options = {}) {
394
+ if (!fs.existsSync(filePath)) {
395
+ throw new Error(`File not found: ${filePath}`);
396
+ }
397
+ return uploadDoc(filePath, {
398
+ ...options,
399
+ filename: options.filename || path.basename(filePath),
400
+ });
401
+ }
402
+
403
+ export async function checkDocsProcessing(docIds) {
404
+ if (!Array.isArray(docIds) || docIds.length === 0) {
405
+ throw new Error("docIds must be a non-empty array");
406
+ }
407
+
408
+ return requestJson(
409
+ `/api/agents/${getConfig().twinId}/docs/check-processing`,
410
+ createJsonRequest({ docIds }),
411
+ "Check doc processing",
412
+ );
413
+ }
414
+
415
+ export async function deleteDoc(docId) {
416
+ if (!docId) throw new Error("docId is required");
417
+ return requestJson(
418
+ `/api/agents/${getConfig().twinId}/docs/${docId}`,
419
+ { method: "DELETE" },
420
+ "Delete doc",
421
+ );
422
+ }
423
+
424
+ export async function getVoice() {
425
+ return requestJson(
426
+ `/api/agents/${getConfig().twinId}/voice`,
427
+ {},
428
+ "Get voice",
429
+ );
430
+ }
431
+
432
+ export async function updateVoice(data) {
433
+ return requestJson(
434
+ `/api/agents/${getConfig().twinId}/voice`,
435
+ createJsonRequest(data),
436
+ "Update voice",
437
+ );
438
+ }
439
+
440
+ export async function designVoice(voiceDescription) {
441
+ if (!voiceDescription || voiceDescription.trim().length < 20) {
442
+ throw new Error("voiceDescription must be at least 20 characters");
443
+ }
444
+
445
+ return requestJson(
446
+ `/api/agents/${getConfig().twinId}/voice/design`,
447
+ createJsonRequest({ voiceDescription: voiceDescription.trim() }),
448
+ "Design voice",
449
+ );
450
+ }
451
+
452
+ export async function createVoice(data) {
453
+ if (!data?.generatedVoiceId) {
454
+ throw new Error("generatedVoiceId is required");
455
+ }
456
+
457
+ return requestJson(
458
+ `/api/agents/${getConfig().twinId}/voice/create`,
459
+ createJsonRequest(data),
460
+ "Create voice",
461
+ );
462
+ }
463
+
464
+ export async function cloneVoice(file, options = {}) {
465
+ const { blob, filename } = createBlobFromFileInput(file, options);
466
+ const formData = new FormData();
467
+ formData.append("audio", blob, filename);
468
+
469
+ return requestJson(
470
+ `/api/agents/${getConfig().twinId}/voice/clone`,
471
+ { method: "POST", body: formData },
472
+ "Clone voice",
473
+ );
474
+ }
475
+
476
+ export async function cloneVoiceFromFile(filePath, options = {}) {
477
+ if (!fs.existsSync(filePath)) {
478
+ throw new Error(`File not found: ${filePath}`);
479
+ }
480
+ return cloneVoice(filePath, {
481
+ ...options,
482
+ filename: options.filename || path.basename(filePath),
483
+ });
484
+ }
485
+
486
+ export async function resetVoice() {
487
+ return requestJson(
488
+ `/api/agents/${getConfig().twinId}/voice/reset`,
489
+ { method: "POST" },
490
+ "Reset voice",
491
+ );
492
+ }
493
+
494
+ export async function updateAvatar(file, options = {}) {
495
+ const { blob, filename } = createBlobFromFileInput(file, options);
496
+ const formData = new FormData();
497
+ formData.append("image", blob, filename);
498
+
499
+ return requestJson(
500
+ `/api/agents/${getConfig().twinId}/avatar`,
501
+ { method: "POST", body: formData },
502
+ "Update avatar",
503
+ );
504
+ }
505
+
506
+ export async function searchFriends(query) {
507
+ if (!query || !query.trim()) {
508
+ throw new Error("query is required");
509
+ }
510
+
511
+ const params = new URLSearchParams({ q: query.trim() });
512
+ return requestJson(`/api/friends/search?${params.toString()}`, {}, "Search friends");
513
+ }
514
+
515
+ export async function listFriends(options = {}) {
516
+ const params = new URLSearchParams();
517
+ if (options.type) params.set("type", options.type);
518
+ if (options.favoritesOnly) params.set("favorites_only", "true");
519
+ const suffix = params.toString() ? `?${params.toString()}` : "";
520
+ return requestJson(`/api/friends${suffix}`, {}, "List friends");
521
+ }
522
+
523
+ export async function listFriendRequests() {
524
+ return requestJson("/api/friends/requests", {}, "List friend requests");
525
+ }
526
+
527
+ export async function sendFriendRequest(friendId) {
528
+ if (!friendId) throw new Error("friendId is required");
529
+ return requestJson(
530
+ "/api/friends",
531
+ createJsonRequest({ friend_id: friendId }),
532
+ "Send friend request",
533
+ );
534
+ }
535
+
536
+ export async function acceptFriendRequest(friendshipId) {
537
+ if (!friendshipId) throw new Error("friendshipId is required");
538
+ return requestJson(
539
+ `/api/friends/${friendshipId}`,
540
+ {
541
+ method: "PATCH",
542
+ headers: { "Content-Type": "application/json" },
543
+ body: JSON.stringify({ action: "accept" }),
544
+ },
545
+ "Accept friend request",
546
+ );
547
+ }
548
+
549
+ export async function declineFriendRequest(friendshipId) {
550
+ if (!friendshipId) throw new Error("friendshipId is required");
551
+ return requestJson(
552
+ `/api/friends/${friendshipId}`,
553
+ {
554
+ method: "PATCH",
555
+ headers: { "Content-Type": "application/json" },
556
+ body: JSON.stringify({ action: "decline" }),
557
+ },
558
+ "Decline friend request",
559
+ );
560
+ }
561
+
562
+ export async function removeFriendship(friendshipId) {
563
+ if (!friendshipId) throw new Error("friendshipId is required");
564
+ return requestJson(
565
+ `/api/friends/${friendshipId}`,
566
+ { method: "DELETE" },
567
+ "Remove friendship",
568
+ );
569
+ }
570
+
571
+ export async function getFeed(options = {}) {
572
+ const params = new URLSearchParams({
573
+ page: String(options.page ?? 1),
574
+ limit: String(options.limit ?? 20),
575
+ });
576
+
577
+ return requestJson(`/api/feed?${params.toString()}`, {}, "Get feed");
578
+ }
579
+
580
+ export async function getPost(postId) {
581
+ if (!postId) throw new Error("postId is required");
582
+ return requestJson(`/api/posts/${postId}`, {}, "Get post");
583
+ }
584
+
585
+ export async function commentOnPost(postId, comment, options = {}) {
586
+ if (!postId) throw new Error("postId is required");
587
+ if (!comment || !comment.trim()) {
588
+ throw new Error("comment is required");
589
+ }
590
+
591
+ return requestJson(
592
+ `/api/posts/${postId}/comments`,
593
+ {
594
+ method: "POST",
595
+ headers: { "Content-Type": "application/json" },
596
+ body: JSON.stringify({
597
+ comment: comment.trim(),
598
+ media_urls: Array.isArray(options.mediaUrls) ? options.mediaUrls : [],
599
+ ...(options.twinId ? { twin_id: options.twinId } : {}),
600
+ }),
601
+ },
602
+ "Comment on post",
603
+ );
604
+ }
605
+
606
+ export async function connectComposioApp(appName, redirectUrl) {
607
+ if (!appName) throw new Error("appName is required");
608
+ return requestJson(
609
+ `/api/agents/${getConfig().twinId}/composio/connect`,
610
+ createJsonRequest({ appName, redirectUrl }),
611
+ "Connect Composio app",
612
+ );
613
+ }
614
+
615
+ export async function listComposioConnections() {
616
+ return requestJson(
617
+ `/api/agents/${getConfig().twinId}/composio/connections`,
618
+ {},
619
+ "List Composio connections",
620
+ );
621
+ }
622
+
623
+ export async function disconnectComposioConnection(connectionId) {
624
+ if (!connectionId) throw new Error("connectionId is required");
625
+ return requestJson(
626
+ `/api/agents/${getConfig().twinId}/composio/connections`,
627
+ {
628
+ method: "DELETE",
629
+ headers: { "Content-Type": "application/json" },
630
+ body: JSON.stringify({ connectionId }),
631
+ },
632
+ "Disconnect Composio connection",
633
+ );
634
+ }