@hmawla/co-assistant 1.0.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.
- package/LICENSE +21 -0
- package/README.md +396 -0
- package/config.json.example +32 -0
- package/dist/cli/index.js +4547 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.js +3258 -0
- package/dist/index.js.map +1 -0
- package/heartbeats/email-reply-check.heartbeat.md +39 -0
- package/package.json +79 -0
- package/personality.md +48 -0
- package/plugins/gmail/README.md +78 -0
- package/plugins/gmail/auth.ts +92 -0
- package/plugins/gmail/index.ts +66 -0
- package/plugins/gmail/plugin.json +13 -0
- package/plugins/gmail/tools.ts +336 -0
- package/plugins/google-calendar/README.md +51 -0
- package/plugins/google-calendar/auth.ts +92 -0
- package/plugins/google-calendar/index.ts +82 -0
- package/plugins/google-calendar/plugin.json +13 -0
- package/plugins/google-calendar/tools.ts +328 -0
- package/user.md.example +38 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module gmail/tools
|
|
3
|
+
* @description Gmail AI tool definitions for searching, reading, and sending emails.
|
|
4
|
+
*
|
|
5
|
+
* Each tool calls the Gmail REST API directly using `fetch` and the access
|
|
6
|
+
* token obtained via {@link GmailAuth}. Tool handlers never throw — they
|
|
7
|
+
* return user-friendly error messages so the AI model can report failures
|
|
8
|
+
* gracefully.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import type { Logger } from "pino";
|
|
13
|
+
import type { ToolDefinition } from "../../src/plugins/types.js";
|
|
14
|
+
import type { GmailAuth } from "./auth.js";
|
|
15
|
+
|
|
16
|
+
/** Base URL for the Gmail v1 REST API. */
|
|
17
|
+
const GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build an `Authorization` header using a fresh access token.
|
|
25
|
+
*/
|
|
26
|
+
async function authHeaders(auth: GmailAuth): Promise<Record<string, string>> {
|
|
27
|
+
const token = await auth.getAccessToken();
|
|
28
|
+
return { Authorization: `Bearer ${token}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decode a base64url-encoded string (as returned by the Gmail API) to UTF-8.
|
|
33
|
+
*
|
|
34
|
+
* Gmail encodes message bodies using the URL-safe base64 variant defined in
|
|
35
|
+
* RFC 4648 §5 — i.e. `+` → `-` and `/` → `_`, with no padding.
|
|
36
|
+
*/
|
|
37
|
+
function decodeBase64Url(encoded: string): string {
|
|
38
|
+
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
39
|
+
return Buffer.from(base64, "base64").toString("utf-8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Encode a UTF-8 string to base64url (for sending raw RFC 2822 messages).
|
|
44
|
+
*/
|
|
45
|
+
function encodeBase64Url(text: string): string {
|
|
46
|
+
return Buffer.from(text, "utf-8")
|
|
47
|
+
.toString("base64")
|
|
48
|
+
.replace(/\+/g, "-")
|
|
49
|
+
.replace(/\//g, "_")
|
|
50
|
+
.replace(/=+$/, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract a header value from a Gmail message resource.
|
|
55
|
+
*/
|
|
56
|
+
function getHeader(
|
|
57
|
+
headers: Array<{ name: string; value: string }>,
|
|
58
|
+
name: string,
|
|
59
|
+
): string {
|
|
60
|
+
return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract the plain-text body from a Gmail message payload.
|
|
65
|
+
*
|
|
66
|
+
* Gmail messages may be simple (body on the payload itself) or multipart.
|
|
67
|
+
* This function walks the MIME tree looking for `text/plain`, falling back
|
|
68
|
+
* to `text/html` when no plain-text part exists.
|
|
69
|
+
*/
|
|
70
|
+
function extractBody(payload: Record<string, unknown>): string {
|
|
71
|
+
// Simple (non-multipart) message — body is directly on the payload.
|
|
72
|
+
const body = payload.body as { data?: string; size?: number } | undefined;
|
|
73
|
+
if (body?.data) {
|
|
74
|
+
return decodeBase64Url(body.data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Multipart — walk parts looking for text/plain, then text/html.
|
|
78
|
+
const parts = (payload.parts ?? []) as Array<Record<string, unknown>>;
|
|
79
|
+
let htmlFallback = "";
|
|
80
|
+
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
const mimeType = part.mimeType as string | undefined;
|
|
83
|
+
const partBody = part.body as { data?: string } | undefined;
|
|
84
|
+
|
|
85
|
+
if (mimeType === "text/plain" && partBody?.data) {
|
|
86
|
+
return decodeBase64Url(partBody.data);
|
|
87
|
+
}
|
|
88
|
+
if (mimeType === "text/html" && partBody?.data) {
|
|
89
|
+
htmlFallback = decodeBase64Url(partBody.data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Recurse into nested multipart parts.
|
|
93
|
+
if (part.parts) {
|
|
94
|
+
const nested = extractBody(part as Record<string, unknown>);
|
|
95
|
+
if (nested) return nested;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return htmlFallback || "(no body)";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Tool Factory
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create the Gmail tool definitions.
|
|
108
|
+
*
|
|
109
|
+
* @param auth - A configured {@link GmailAuth} instance for token management.
|
|
110
|
+
* @param logger - Plugin-scoped logger.
|
|
111
|
+
* @returns An array of {@link ToolDefinition} objects for the plugin manager.
|
|
112
|
+
*/
|
|
113
|
+
export function createGmailTools(auth: GmailAuth, logger: Logger): ToolDefinition[] {
|
|
114
|
+
// -----------------------------------------------------------------------
|
|
115
|
+
// search_emails
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
const searchEmails: ToolDefinition = {
|
|
118
|
+
name: "search_emails",
|
|
119
|
+
description:
|
|
120
|
+
"Search for emails in Gmail using a query string (same syntax as the Gmail search bar)",
|
|
121
|
+
parameters: z.object({
|
|
122
|
+
/** Gmail search query (e.g. "from:alice subject:meeting"). */
|
|
123
|
+
query: z.string().describe("Gmail search query"),
|
|
124
|
+
/** Maximum number of results to return (default 10, max 50). */
|
|
125
|
+
maxResults: z
|
|
126
|
+
.number()
|
|
127
|
+
.int()
|
|
128
|
+
.min(1)
|
|
129
|
+
.max(50)
|
|
130
|
+
.optional()
|
|
131
|
+
.default(10)
|
|
132
|
+
.describe("Maximum number of results to return"),
|
|
133
|
+
}),
|
|
134
|
+
|
|
135
|
+
handler: async (args) => {
|
|
136
|
+
try {
|
|
137
|
+
const query = args.query as string;
|
|
138
|
+
const maxResults = (args.maxResults as number | undefined) ?? 10;
|
|
139
|
+
logger.debug({ query, maxResults }, "search_emails called");
|
|
140
|
+
|
|
141
|
+
// Step 1 — List message IDs matching the query.
|
|
142
|
+
const params = new URLSearchParams({
|
|
143
|
+
q: query,
|
|
144
|
+
maxResults: String(maxResults),
|
|
145
|
+
});
|
|
146
|
+
const listRes = await fetch(`${GMAIL_API}/messages?${params}`, {
|
|
147
|
+
headers: await authHeaders(auth),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (!listRes.ok) {
|
|
151
|
+
const errText = await listRes.text();
|
|
152
|
+
logger.error({ status: listRes.status, errText }, "Gmail search failed");
|
|
153
|
+
return `Error searching emails (${listRes.status}): ${errText}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const listData = (await listRes.json()) as {
|
|
157
|
+
messages?: Array<{ id: string; threadId: string }>;
|
|
158
|
+
resultSizeEstimate?: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (!listData.messages?.length) {
|
|
162
|
+
return "No emails found matching that query.";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step 2 — Fetch each message's metadata (Subject, From, Date, snippet).
|
|
166
|
+
const headers = await authHeaders(auth);
|
|
167
|
+
const results = await Promise.all(
|
|
168
|
+
listData.messages.map(async (msg) => {
|
|
169
|
+
const msgRes = await fetch(
|
|
170
|
+
`${GMAIL_API}/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
|
|
171
|
+
{ headers },
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!msgRes.ok) {
|
|
175
|
+
return { id: msg.id, error: `Failed to fetch (${msgRes.status})` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const msgData = (await msgRes.json()) as {
|
|
179
|
+
id: string;
|
|
180
|
+
snippet: string;
|
|
181
|
+
payload: { headers: Array<{ name: string; value: string }> };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
id: msgData.id,
|
|
186
|
+
subject: getHeader(msgData.payload.headers, "Subject"),
|
|
187
|
+
from: getHeader(msgData.payload.headers, "From"),
|
|
188
|
+
date: getHeader(msgData.payload.headers, "Date"),
|
|
189
|
+
snippet: msgData.snippet,
|
|
190
|
+
};
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
resultCount: results.length,
|
|
196
|
+
estimatedTotal: listData.resultSizeEstimate ?? results.length,
|
|
197
|
+
messages: results,
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
logger.error({ error: message }, "search_emails error");
|
|
202
|
+
return `Error searching emails: ${message}`;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// -----------------------------------------------------------------------
|
|
208
|
+
// read_email
|
|
209
|
+
// -----------------------------------------------------------------------
|
|
210
|
+
const readEmail: ToolDefinition = {
|
|
211
|
+
name: "read_email",
|
|
212
|
+
description: "Read the full content of a specific email by its message ID",
|
|
213
|
+
parameters: z.object({
|
|
214
|
+
/** The Gmail message ID (returned by search_emails). */
|
|
215
|
+
messageId: z.string().describe("Gmail message ID"),
|
|
216
|
+
}),
|
|
217
|
+
|
|
218
|
+
handler: async (args) => {
|
|
219
|
+
try {
|
|
220
|
+
const messageId = args.messageId as string;
|
|
221
|
+
logger.debug({ messageId }, "read_email called");
|
|
222
|
+
|
|
223
|
+
// Fetch the full message in "full" format to get decoded body parts.
|
|
224
|
+
const res = await fetch(`${GMAIL_API}/messages/${messageId}?format=full`, {
|
|
225
|
+
headers: await authHeaders(auth),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!res.ok) {
|
|
229
|
+
const errText = await res.text();
|
|
230
|
+
logger.error({ status: res.status, errText }, "Gmail read failed");
|
|
231
|
+
return `Error reading email (${res.status}): ${errText}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const data = (await res.json()) as {
|
|
235
|
+
id: string;
|
|
236
|
+
threadId: string;
|
|
237
|
+
labelIds: string[];
|
|
238
|
+
snippet: string;
|
|
239
|
+
payload: {
|
|
240
|
+
headers: Array<{ name: string; value: string }>;
|
|
241
|
+
body?: { data?: string };
|
|
242
|
+
parts?: Array<Record<string, unknown>>;
|
|
243
|
+
mimeType?: string;
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const hdrs = data.payload.headers;
|
|
248
|
+
const body = extractBody(data.payload as Record<string, unknown>);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
id: data.id,
|
|
252
|
+
threadId: data.threadId,
|
|
253
|
+
labels: data.labelIds,
|
|
254
|
+
subject: getHeader(hdrs, "Subject"),
|
|
255
|
+
from: getHeader(hdrs, "From"),
|
|
256
|
+
to: getHeader(hdrs, "To"),
|
|
257
|
+
date: getHeader(hdrs, "Date"),
|
|
258
|
+
body,
|
|
259
|
+
};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
262
|
+
logger.error({ error: message }, "read_email error");
|
|
263
|
+
return `Error reading email: ${message}`;
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// -----------------------------------------------------------------------
|
|
269
|
+
// send_email
|
|
270
|
+
// -----------------------------------------------------------------------
|
|
271
|
+
const sendEmail: ToolDefinition = {
|
|
272
|
+
name: "send_email",
|
|
273
|
+
description: "Send an email via Gmail",
|
|
274
|
+
parameters: z.object({
|
|
275
|
+
/** Recipient email address. */
|
|
276
|
+
to: z.string().email().describe("Recipient email address"),
|
|
277
|
+
/** Email subject line. */
|
|
278
|
+
subject: z.string().describe("Email subject"),
|
|
279
|
+
/** Plain-text email body. */
|
|
280
|
+
body: z.string().describe("Email body (plain text)"),
|
|
281
|
+
}),
|
|
282
|
+
|
|
283
|
+
handler: async (args) => {
|
|
284
|
+
try {
|
|
285
|
+
const to = args.to as string;
|
|
286
|
+
const subject = args.subject as string;
|
|
287
|
+
const body = args.body as string;
|
|
288
|
+
logger.debug({ to, subject }, "send_email called");
|
|
289
|
+
|
|
290
|
+
// Build an RFC 2822 message and encode it as base64url.
|
|
291
|
+
const rfc2822 = [
|
|
292
|
+
`To: ${to}`,
|
|
293
|
+
`Subject: ${subject}`,
|
|
294
|
+
"Content-Type: text/plain; charset=UTF-8",
|
|
295
|
+
"MIME-Version: 1.0",
|
|
296
|
+
"",
|
|
297
|
+
body,
|
|
298
|
+
].join("\r\n");
|
|
299
|
+
|
|
300
|
+
const raw = encodeBase64Url(rfc2822);
|
|
301
|
+
|
|
302
|
+
// POST the raw message to the Gmail send endpoint.
|
|
303
|
+
const res = await fetch(`${GMAIL_API}/messages/send`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
...(await authHeaders(auth)),
|
|
307
|
+
"Content-Type": "application/json",
|
|
308
|
+
},
|
|
309
|
+
body: JSON.stringify({ raw }),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!res.ok) {
|
|
313
|
+
const errText = await res.text();
|
|
314
|
+
logger.error({ status: res.status, errText }, "Gmail send failed");
|
|
315
|
+
return `Error sending email (${res.status}): ${errText}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const data = (await res.json()) as { id: string; threadId: string };
|
|
319
|
+
logger.info({ id: data.id, to }, "Email sent successfully");
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
success: true,
|
|
323
|
+
messageId: data.id,
|
|
324
|
+
threadId: data.threadId,
|
|
325
|
+
message: `Email sent successfully to ${to}`,
|
|
326
|
+
};
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
329
|
+
logger.error({ error: message }, "send_email error");
|
|
330
|
+
return `Error sending email: ${message}`;
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return [searchEmails, readEmail, sendEmail];
|
|
336
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Google Calendar Plugin
|
|
2
|
+
|
|
3
|
+
View, create, update, and delete Google Calendar events from your Co-Assistant bot.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| `list_events` | List upcoming events with optional date-range filtering |
|
|
10
|
+
| `create_event` | Create a new calendar event with attendees and location |
|
|
11
|
+
| `update_event` | Update an existing event by ID |
|
|
12
|
+
| `delete_event` | Delete an event by ID |
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
### 1. Create Google Cloud credentials
|
|
17
|
+
|
|
18
|
+
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
|
19
|
+
2. Create a new project (or select an existing one).
|
|
20
|
+
3. Configure the **OAuth consent screen** (APIs & Services → OAuth consent screen).
|
|
21
|
+
- Set to "External" for personal use, add your email as a test user.
|
|
22
|
+
4. Navigate to **APIs & Services → Library** and enable the **Google Calendar API**.
|
|
23
|
+
5. Go to **APIs & Services → Credentials** and create an **OAuth 2.0 Client ID**.
|
|
24
|
+
- Application type: **Desktop app** (recommended — allows automatic localhost redirect).
|
|
25
|
+
6. Download the **client secret JSON file**.
|
|
26
|
+
|
|
27
|
+
### 2. Run the setup wizard
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx tsx src/cli/index.ts setup --plugin google-calendar
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The setup will:
|
|
34
|
+
- Ask for your downloaded JSON file path (extracts credentials, does not store the file)
|
|
35
|
+
- Open your browser to authorize Google Calendar access
|
|
36
|
+
- Capture the refresh token automatically via a local callback server
|
|
37
|
+
|
|
38
|
+
| Key | Description |
|
|
39
|
+
|-----|-------------|
|
|
40
|
+
| `GCAL_CLIENT_ID` | Google OAuth2 Client ID |
|
|
41
|
+
| `GCAL_CLIENT_SECRET` | Google OAuth2 Client Secret |
|
|
42
|
+
| `GCAL_REFRESH_TOKEN` | Google OAuth2 Refresh Token |
|
|
43
|
+
|
|
44
|
+
## Usage Examples
|
|
45
|
+
|
|
46
|
+
Once configured, the AI assistant can use natural language to manage your calendar:
|
|
47
|
+
|
|
48
|
+
- *"What's on my calendar today?"*
|
|
49
|
+
- *"Schedule a meeting with alice@example.com tomorrow at 2 PM for one hour."*
|
|
50
|
+
- *"Update event abc123 to start at 3 PM instead."*
|
|
51
|
+
- *"Delete the event with ID xyz789."*
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module google-calendar/auth
|
|
3
|
+
* @description Google Calendar OAuth2 authentication handler.
|
|
4
|
+
*
|
|
5
|
+
* Manages access-token refresh via the Google OAuth2 token endpoint.
|
|
6
|
+
* Tokens are cached in memory and automatically refreshed when they
|
|
7
|
+
* expire (with a 60-second safety margin).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Google OAuth2 token endpoint. */
|
|
11
|
+
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
12
|
+
|
|
13
|
+
/** Safety margin (ms) subtracted from `expires_in` to avoid edge-case expiry. */
|
|
14
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lightweight Google OAuth2 helper that handles the refresh-token flow.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const auth = new CalendarAuth(clientId, clientSecret, refreshToken);
|
|
22
|
+
* const token = await auth.getAccessToken();
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class CalendarAuth {
|
|
26
|
+
private readonly clientId: string;
|
|
27
|
+
private readonly clientSecret: string;
|
|
28
|
+
private readonly refreshToken: string;
|
|
29
|
+
|
|
30
|
+
private accessToken: string | null = null;
|
|
31
|
+
private expiresAt = 0;
|
|
32
|
+
|
|
33
|
+
constructor(clientId: string, clientSecret: string, refreshToken: string) {
|
|
34
|
+
this.clientId = clientId;
|
|
35
|
+
this.clientSecret = clientSecret;
|
|
36
|
+
this.refreshToken = refreshToken;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check whether all required credentials have been provided.
|
|
41
|
+
*/
|
|
42
|
+
isConfigured(): boolean {
|
|
43
|
+
return Boolean(this.clientId && this.clientSecret && this.refreshToken);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return a valid access token, refreshing it first if necessary.
|
|
48
|
+
*
|
|
49
|
+
* @throws {Error} If credentials are missing or the token exchange fails.
|
|
50
|
+
*/
|
|
51
|
+
async getAccessToken(): Promise<string> {
|
|
52
|
+
if (!this.isConfigured()) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"CalendarAuth is not configured — client ID, client secret, and refresh token are all required.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (this.accessToken && Date.now() < this.expiresAt) {
|
|
59
|
+
return this.accessToken;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = new URLSearchParams({
|
|
63
|
+
client_id: this.clientId,
|
|
64
|
+
client_secret: this.clientSecret,
|
|
65
|
+
refresh_token: this.refreshToken,
|
|
66
|
+
grant_type: "refresh_token",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const response = await fetch(TOKEN_ENDPOINT, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
72
|
+
body: body.toString(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const text = await response.text();
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Failed to refresh Google access token (${response.status}): ${text}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const data = (await response.json()) as {
|
|
83
|
+
access_token: string;
|
|
84
|
+
expires_in: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.accessToken = data.access_token;
|
|
88
|
+
this.expiresAt = Date.now() + data.expires_in * 1000 - EXPIRY_MARGIN_MS;
|
|
89
|
+
|
|
90
|
+
return this.accessToken;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module google-calendar
|
|
3
|
+
* @description Google Calendar plugin — view, create, update, and delete
|
|
4
|
+
* calendar events via the Google Calendar v3 API.
|
|
5
|
+
*
|
|
6
|
+
* This is a reference implementation showing how to build a fully-featured
|
|
7
|
+
* Co-Assistant plugin with OAuth2 authentication and multiple tool definitions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CoAssistantPlugin,
|
|
12
|
+
PluginContext,
|
|
13
|
+
PluginFactory,
|
|
14
|
+
ToolDefinition,
|
|
15
|
+
} from "../../src/plugins/types.js";
|
|
16
|
+
import { CalendarAuth } from "./auth.js";
|
|
17
|
+
import { createCalendarTools } from "./tools.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Factory function that creates a new Google Calendar plugin instance.
|
|
21
|
+
*
|
|
22
|
+
* This is the default export expected by the plugin loader (see
|
|
23
|
+
* {@link PluginFactory}).
|
|
24
|
+
*/
|
|
25
|
+
const createPlugin: PluginFactory = (): CoAssistantPlugin => {
|
|
26
|
+
let auth: CalendarAuth;
|
|
27
|
+
let tools: ToolDefinition[] = [];
|
|
28
|
+
let logger: PluginContext["logger"];
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
id: "google-calendar",
|
|
32
|
+
name: "Google Calendar Plugin",
|
|
33
|
+
version: "1.0.0",
|
|
34
|
+
description: "View, create, and manage Google Calendar events",
|
|
35
|
+
requiredCredentials: [
|
|
36
|
+
"GCAL_CLIENT_ID",
|
|
37
|
+
"GCAL_CLIENT_SECRET",
|
|
38
|
+
"GCAL_REFRESH_TOKEN",
|
|
39
|
+
],
|
|
40
|
+
|
|
41
|
+
async initialize(context: PluginContext): Promise<void> {
|
|
42
|
+
logger = context.logger;
|
|
43
|
+
logger.info("Initialising Google Calendar plugin…");
|
|
44
|
+
|
|
45
|
+
auth = new CalendarAuth(
|
|
46
|
+
context.credentials.GCAL_CLIENT_ID,
|
|
47
|
+
context.credentials.GCAL_CLIENT_SECRET,
|
|
48
|
+
context.credentials.GCAL_REFRESH_TOKEN,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (!auth.isConfigured()) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"Google Calendar plugin is missing one or more required credentials.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
tools = createCalendarTools(auth);
|
|
58
|
+
logger.info(`Registered ${tools.length} calendar tools`);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
getTools(): ToolDefinition[] {
|
|
62
|
+
return tools;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async destroy(): Promise<void> {
|
|
66
|
+
tools = [];
|
|
67
|
+
logger?.info("Google Calendar plugin destroyed");
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async healthCheck(): Promise<boolean> {
|
|
71
|
+
try {
|
|
72
|
+
// A lightweight token refresh confirms the credentials are still valid.
|
|
73
|
+
await auth.getAccessToken();
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default createPlugin;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "google-calendar",
|
|
3
|
+
"name": "Google Calendar Plugin",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "View, create, and manage Google Calendar events",
|
|
6
|
+
"author": "co-assistant",
|
|
7
|
+
"requiredCredentials": [
|
|
8
|
+
{ "key": "GCAL_CLIENT_ID", "description": "Google OAuth2 Client ID", "type": "oauth" },
|
|
9
|
+
{ "key": "GCAL_CLIENT_SECRET", "description": "Google OAuth2 Client Secret", "type": "oauth" },
|
|
10
|
+
{ "key": "GCAL_REFRESH_TOKEN", "description": "Google OAuth2 Refresh Token", "type": "oauth" }
|
|
11
|
+
],
|
|
12
|
+
"dependencies": []
|
|
13
|
+
}
|