@frockbot/plugin-composio 0.0.0 → 0.1.1

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.
@@ -0,0 +1,232 @@
1
+ export type ComposioFetch = (
2
+ input: string | URL | Request,
3
+ init?: RequestInit,
4
+ ) => Promise<Response>;
5
+
6
+ export interface ComposioClientConfig {
7
+ apiKey: string;
8
+ baseUrl?: string;
9
+ fetch?: ComposioFetch;
10
+ }
11
+
12
+ export interface CreateConnectLinkInput {
13
+ userId: string;
14
+ authConfigId: string;
15
+ callbackUrl: string;
16
+ alias?: string;
17
+ }
18
+
19
+ export interface ConnectLink {
20
+ connectedAccountId: string;
21
+ redirectUrl: string;
22
+ expiresAt: string;
23
+ }
24
+
25
+ export interface ComposioToolSummary {
26
+ slug: string;
27
+ name: string;
28
+ description?: string;
29
+ }
30
+
31
+ export interface ConnectedAccountSummary {
32
+ id: string;
33
+ status: string;
34
+ toolkitSlug: string;
35
+ alias?: string;
36
+ userId?: string;
37
+ }
38
+
39
+ export interface ExecuteComposioToolInput {
40
+ toolSlug: string;
41
+ userId: string;
42
+ connectedAccountId: string;
43
+ arguments: Record<string, unknown>;
44
+ version?: string;
45
+ }
46
+
47
+ function asRecord(value: unknown): Record<string, unknown> {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ throw new Error("Composio returned an invalid response");
50
+ }
51
+ return value as Record<string, unknown>;
52
+ }
53
+
54
+ function requiredString(value: Record<string, unknown>, key: string): string {
55
+ const candidate = value[key];
56
+ if (typeof candidate !== "string" || !candidate) {
57
+ throw new Error(`Composio response omitted ${key}`);
58
+ }
59
+ return candidate;
60
+ }
61
+
62
+ function connectedAccountSummary(
63
+ value: unknown,
64
+ requireUserId = false,
65
+ ): ConnectedAccountSummary {
66
+ const account = asRecord(value);
67
+ const toolkit = asRecord(account.toolkit);
68
+ const userId = requireUserId
69
+ ? requiredString(account, "user_id")
70
+ : typeof account.user_id === "string"
71
+ ? account.user_id
72
+ : undefined;
73
+ return {
74
+ id: requiredString(account, "id"),
75
+ status: requiredString(account, "status"),
76
+ toolkitSlug: requiredString(toolkit, "slug"),
77
+ alias: typeof account.alias === "string" ? account.alias : undefined,
78
+ ...(userId ? { userId } : {}),
79
+ };
80
+ }
81
+
82
+ export class ComposioRequestError extends Error {
83
+ constructor(readonly status: number) {
84
+ super(`Composio request failed (${status})`);
85
+ this.name = "ComposioRequestError";
86
+ }
87
+ }
88
+
89
+ export class ComposioClient {
90
+ private readonly apiKey: string;
91
+ private readonly baseUrl: string;
92
+ private readonly fetcher: ComposioFetch;
93
+
94
+ constructor(config: ComposioClientConfig) {
95
+ if (!config.apiKey.trim()) throw new Error("Composio API key is required");
96
+ this.apiKey = config.apiKey;
97
+ this.baseUrl = (
98
+ config.baseUrl ?? "https://backend.composio.dev/api/v3.1"
99
+ ).replace(/\/$/, "");
100
+ this.fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);
101
+ }
102
+
103
+ async createConnectLink(input: CreateConnectLinkInput): Promise<ConnectLink> {
104
+ const value = asRecord(
105
+ await this.request("/connected_accounts/link", {
106
+ method: "POST",
107
+ body: JSON.stringify({
108
+ auth_config_id: input.authConfigId,
109
+ user_id: input.userId,
110
+ callback_url: input.callbackUrl,
111
+ ...(input.alias ? { alias: input.alias } : {}),
112
+ }),
113
+ }),
114
+ );
115
+ return {
116
+ connectedAccountId: requiredString(value, "connected_account_id"),
117
+ redirectUrl: requiredString(value, "redirect_url"),
118
+ expiresAt: requiredString(value, "expires_at"),
119
+ };
120
+ }
121
+
122
+ async listConnectedAccounts(
123
+ userId: string,
124
+ ): Promise<ConnectedAccountSummary[]> {
125
+ const accounts: ConnectedAccountSummary[] = [];
126
+ const seenCursors = new Set<string>();
127
+ let cursor: string | undefined;
128
+ for (let page = 0; page < 100; page += 1) {
129
+ const query = new URLSearchParams();
130
+ query.append("user_ids", userId);
131
+ query.append("limit", "100");
132
+ if (cursor) query.append("cursor", cursor);
133
+ const value = asRecord(
134
+ await this.request(`/connected_accounts?${query.toString()}`),
135
+ );
136
+ if (!Array.isArray(value.items)) {
137
+ throw new Error("Composio returned an invalid account list");
138
+ }
139
+ accounts.push(
140
+ ...value.items.map((candidate) => connectedAccountSummary(candidate)),
141
+ );
142
+ const nextCursor = value.next_cursor;
143
+ if (
144
+ nextCursor === undefined ||
145
+ nextCursor === null ||
146
+ nextCursor === ""
147
+ ) {
148
+ return accounts;
149
+ }
150
+ if (typeof nextCursor !== "string" || seenCursors.has(nextCursor)) {
151
+ throw new Error("Composio returned an invalid account cursor");
152
+ }
153
+ seenCursors.add(nextCursor);
154
+ cursor = nextCursor;
155
+ }
156
+ throw new Error("Composio account pagination exceeded its limit");
157
+ }
158
+
159
+ async searchTools(
160
+ toolkitSlug: string,
161
+ search?: string,
162
+ ): Promise<ComposioToolSummary[]> {
163
+ const query = new URLSearchParams();
164
+ query.append("toolkit_slugs", toolkitSlug);
165
+ if (search?.trim()) query.append("search", search.trim());
166
+ const value = asRecord(await this.request(`/tools?${query.toString()}`));
167
+ if (!Array.isArray(value.items)) {
168
+ throw new Error("Composio returned an invalid tool list");
169
+ }
170
+ return value.items.map((candidate) => {
171
+ const tool = asRecord(candidate);
172
+ return {
173
+ slug: requiredString(tool, "slug"),
174
+ name: requiredString(tool, "name"),
175
+ description:
176
+ typeof tool.description === "string" ? tool.description : undefined,
177
+ };
178
+ });
179
+ }
180
+
181
+ async getConnectedAccount(
182
+ connectedAccountId: string,
183
+ ): Promise<ConnectedAccountSummary> {
184
+ return connectedAccountSummary(
185
+ await this.request(
186
+ `/connected_accounts/${encodeURIComponent(connectedAccountId)}`,
187
+ ),
188
+ true,
189
+ );
190
+ }
191
+
192
+ revokeConnectedAccount(connectedAccountId: string): Promise<unknown> {
193
+ return this.request(
194
+ `/connected_accounts/${encodeURIComponent(connectedAccountId)}/revoke`,
195
+ { method: "POST" },
196
+ );
197
+ }
198
+
199
+ executeTool(input: ExecuteComposioToolInput): Promise<unknown> {
200
+ return this.request(
201
+ `/tools/execute/${encodeURIComponent(input.toolSlug)}`,
202
+ {
203
+ method: "POST",
204
+ body: JSON.stringify({
205
+ user_id: input.userId,
206
+ connected_account_id: input.connectedAccountId,
207
+ version: input.version ?? "latest",
208
+ arguments: input.arguments,
209
+ }),
210
+ },
211
+ );
212
+ }
213
+
214
+ private async request(
215
+ path: string,
216
+ init: RequestInit = {},
217
+ ): Promise<unknown> {
218
+ const headers = new Headers(init.headers);
219
+ if (!headers.has("content-type")) {
220
+ headers.set("content-type", "application/json");
221
+ }
222
+ headers.set("x-api-key", this.apiKey);
223
+ const response = await this.fetcher(`${this.baseUrl}${path}`, {
224
+ ...init,
225
+ headers,
226
+ });
227
+ if (!response.ok) {
228
+ throw new ComposioRequestError(response.status);
229
+ }
230
+ return response.json();
231
+ }
232
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConnectionView } from "@frockbot/configuration-core";
3
+ import {
4
+ completeAssignmentCompensation,
5
+ isSettledBotCompensation,
6
+ } from "./connection-recovery.js";
7
+
8
+ function connection(
9
+ state: ConnectionView["state"],
10
+ safeMetadata: ConnectionView["safeMetadata"],
11
+ ): ConnectionView {
12
+ return {
13
+ connectionId: "connection-1",
14
+ packageId: "composio",
15
+ connectionTypeId: "gmail",
16
+ displayName: "Gmail",
17
+ state,
18
+ safeMetadata,
19
+ };
20
+ }
21
+
22
+ describe("Connection recovery", () => {
23
+ test("treats stale Bot generations as settled compensation", () => {
24
+ expect(isSettledBotCompensation("stale")).toBe(true);
25
+ expect(isSettledBotCompensation("applied")).toBe(true);
26
+ });
27
+
28
+ test("keeps revocation scheduled until every generation settles", () => {
29
+ const revoking = connection("revoking", {
30
+ assignmentCompensationPending: true,
31
+ compensationRetryAt: Date.now() + 60_000,
32
+ assignmentCompensations: [
33
+ { botId: "primary", id: "old", expectedGeneration: "gen-old" },
34
+ { botId: "primary", id: "new", expectedGeneration: "gen-new" },
35
+ ],
36
+ });
37
+
38
+ const afterOld = completeAssignmentCompensation(revoking, "old");
39
+ const afterNew = afterOld
40
+ ? completeAssignmentCompensation(afterOld, "new")
41
+ : undefined;
42
+
43
+ expect(afterOld?.safeMetadata).toMatchObject({
44
+ assignmentCompensationPending: true,
45
+ assignmentCompensations: [
46
+ { botId: "primary", id: "new", expectedGeneration: "gen-new" },
47
+ ],
48
+ });
49
+ expect(afterNew?.safeMetadata.assignmentCompensations).toEqual([]);
50
+ expect(afterNew?.safeMetadata).not.toHaveProperty(
51
+ "assignmentCompensationPending",
52
+ );
53
+ });
54
+ });
@@ -0,0 +1,52 @@
1
+ import type { ConnectionView } from "@frockbot/configuration-core";
2
+
3
+ export type BotCompensationResult = "applied" | "stale";
4
+
5
+ export function isSettledBotCompensation(
6
+ result: BotCompensationResult,
7
+ ): boolean {
8
+ return result === "applied" || result === "stale";
9
+ }
10
+
11
+ export function completeAssignmentCompensation(
12
+ connection: ConnectionView,
13
+ compensationId: string,
14
+ ): ConnectionView | undefined {
15
+ if (Array.isArray(connection.safeMetadata.assignmentCompensations)) {
16
+ const remaining = connection.safeMetadata.assignmentCompensations.filter(
17
+ (candidate) =>
18
+ !candidate ||
19
+ typeof candidate !== "object" ||
20
+ Array.isArray(candidate) ||
21
+ (candidate as Record<string, unknown>).id !== compensationId,
22
+ );
23
+ if (
24
+ remaining.length ===
25
+ connection.safeMetadata.assignmentCompensations.length
26
+ ) {
27
+ return undefined;
28
+ }
29
+ const {
30
+ compensationRetryAt,
31
+ assignmentCompensationPending: _,
32
+ ...safeMetadata
33
+ } = connection.safeMetadata;
34
+ return {
35
+ ...connection,
36
+ safeMetadata: {
37
+ ...safeMetadata,
38
+ assignmentCompensations: remaining,
39
+ ...(remaining.length > 0
40
+ ? {
41
+ assignmentCompensationPending: true,
42
+ compensationRetryAt:
43
+ typeof compensationRetryAt === "number"
44
+ ? compensationRetryAt
45
+ : Date.now() + 60_000,
46
+ }
47
+ : {}),
48
+ },
49
+ };
50
+ }
51
+ return undefined;
52
+ }