agentgate 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.
- package/README.md +216 -0
- package/package.json +63 -0
- package/public/favicon.svg +48 -0
- package/public/icons/bluesky.svg +1 -0
- package/public/icons/fitbit.svg +16 -0
- package/public/icons/github.svg +1 -0
- package/public/icons/google-calendar.svg +1 -0
- package/public/icons/jira.svg +1 -0
- package/public/icons/linkedin.svg +1 -0
- package/public/icons/mastodon.svg +1 -0
- package/public/icons/reddit.svg +1 -0
- package/public/icons/youtube.svg +1 -0
- package/public/logo.svg +52 -0
- package/public/style.css +584 -0
- package/src/cli.js +77 -0
- package/src/index.js +344 -0
- package/src/lib/db.js +325 -0
- package/src/lib/hsyncManager.js +57 -0
- package/src/lib/queueExecutor.js +362 -0
- package/src/routes/bluesky.js +130 -0
- package/src/routes/calendar.js +120 -0
- package/src/routes/fitbit.js +127 -0
- package/src/routes/github.js +72 -0
- package/src/routes/jira.js +77 -0
- package/src/routes/linkedin.js +137 -0
- package/src/routes/mastodon.js +91 -0
- package/src/routes/queue.js +186 -0
- package/src/routes/reddit.js +138 -0
- package/src/routes/ui/bluesky.js +66 -0
- package/src/routes/ui/calendar.js +120 -0
- package/src/routes/ui/fitbit.js +122 -0
- package/src/routes/ui/github.js +60 -0
- package/src/routes/ui/index.js +35 -0
- package/src/routes/ui/jira.js +72 -0
- package/src/routes/ui/linkedin.js +120 -0
- package/src/routes/ui/mastodon.js +140 -0
- package/src/routes/ui/reddit.js +120 -0
- package/src/routes/ui/youtube.js +120 -0
- package/src/routes/ui.js +1077 -0
- package/src/routes/youtube.js +119 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createConnection } from 'hsync';
|
|
2
|
+
import { getSetting } from './db.js';
|
|
3
|
+
|
|
4
|
+
let currentConnection = null;
|
|
5
|
+
let currentUrl = null;
|
|
6
|
+
|
|
7
|
+
export async function connectHsync(port) {
|
|
8
|
+
const config = getSetting('hsync');
|
|
9
|
+
if (!config?.enabled) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Disconnect existing connection first
|
|
14
|
+
await disconnectHsync();
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const options = {
|
|
18
|
+
port,
|
|
19
|
+
hsyncServer: config.url,
|
|
20
|
+
hsyncSecret: config.token || undefined
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
currentConnection = await createConnection(options);
|
|
24
|
+
currentUrl = currentConnection.publicUrl || currentConnection.url || config.url || null;
|
|
25
|
+
console.log(`hsync connected: ${currentUrl || 'connected'}`);
|
|
26
|
+
return currentConnection;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('hsync connection failed:', err.message);
|
|
29
|
+
currentConnection = null;
|
|
30
|
+
currentUrl = null;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function disconnectHsync() {
|
|
36
|
+
if (currentConnection) {
|
|
37
|
+
try {
|
|
38
|
+
if (typeof currentConnection.close === 'function') {
|
|
39
|
+
await currentConnection.close();
|
|
40
|
+
} else if (typeof currentConnection.disconnect === 'function') {
|
|
41
|
+
await currentConnection.disconnect();
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Disconnect errors are non-fatal
|
|
45
|
+
}
|
|
46
|
+
currentConnection = null;
|
|
47
|
+
currentUrl = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getHsyncUrl() {
|
|
52
|
+
return currentUrl;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isHsyncConnected() {
|
|
56
|
+
return currentConnection !== null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { getAccountCredentials, setAccountCredentials, updateQueueStatus } from './db.js';
|
|
2
|
+
|
|
3
|
+
// Service base URLs
|
|
4
|
+
const SERVICE_URLS = {
|
|
5
|
+
github: 'https://api.github.com',
|
|
6
|
+
bluesky: 'https://bsky.social/xrpc',
|
|
7
|
+
reddit: 'https://oauth.reddit.com',
|
|
8
|
+
mastodon: null, // Dynamic: https://{instance}
|
|
9
|
+
calendar: 'https://www.googleapis.com/calendar/v3',
|
|
10
|
+
google_calendar: 'https://www.googleapis.com/calendar/v3',
|
|
11
|
+
youtube: 'https://www.googleapis.com/youtube/v3',
|
|
12
|
+
linkedin: 'https://api.linkedin.com/v2',
|
|
13
|
+
jira: null, // Dynamic: https://{domain}/rest/api/3
|
|
14
|
+
fitbit: 'https://api.fitbit.com'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Get access token for a service, refreshing if needed
|
|
18
|
+
async function getAccessToken(service, accountName) {
|
|
19
|
+
const creds = getAccountCredentials(service, accountName);
|
|
20
|
+
if (!creds) return null;
|
|
21
|
+
|
|
22
|
+
switch (service) {
|
|
23
|
+
case 'github':
|
|
24
|
+
return creds.token || null;
|
|
25
|
+
|
|
26
|
+
case 'bluesky':
|
|
27
|
+
return await getBlueskyToken(accountName, creds);
|
|
28
|
+
|
|
29
|
+
case 'reddit':
|
|
30
|
+
return await getOAuthToken(accountName, creds, 'reddit', refreshRedditToken);
|
|
31
|
+
|
|
32
|
+
case 'calendar':
|
|
33
|
+
case 'google_calendar':
|
|
34
|
+
return await getOAuthToken(accountName, creds, 'google_calendar', refreshGoogleToken);
|
|
35
|
+
|
|
36
|
+
case 'youtube':
|
|
37
|
+
return await getOAuthToken(accountName, creds, 'youtube', refreshGoogleToken);
|
|
38
|
+
|
|
39
|
+
case 'linkedin':
|
|
40
|
+
return await getOAuthToken(accountName, creds, 'linkedin', refreshLinkedInToken);
|
|
41
|
+
|
|
42
|
+
case 'mastodon':
|
|
43
|
+
return creds.accessToken || null;
|
|
44
|
+
|
|
45
|
+
case 'jira':
|
|
46
|
+
// Jira uses basic auth, return the creds object
|
|
47
|
+
return creds;
|
|
48
|
+
|
|
49
|
+
case 'fitbit':
|
|
50
|
+
return await getOAuthToken(accountName, creds, 'fitbit', refreshFitbitToken);
|
|
51
|
+
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Generic OAuth token getter with refresh
|
|
58
|
+
async function getOAuthToken(accountName, creds, service, refreshFn) {
|
|
59
|
+
if (creds.accessToken && creds.expiresAt && Date.now() < creds.expiresAt) {
|
|
60
|
+
return creds.accessToken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!creds.refreshToken) return null;
|
|
64
|
+
|
|
65
|
+
const newToken = await refreshFn(accountName, creds, service);
|
|
66
|
+
return newToken;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Bluesky session token
|
|
70
|
+
async function getBlueskyToken(accountName, creds) {
|
|
71
|
+
if (creds.accessJwt && creds.expiresAt && Date.now() < creds.expiresAt) {
|
|
72
|
+
return creds.accessJwt;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch('https://bsky.social/xrpc/com.atproto.server.createSession', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
identifier: creds.identifier,
|
|
81
|
+
password: creds.appPassword
|
|
82
|
+
})
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) return null;
|
|
86
|
+
|
|
87
|
+
const session = await response.json();
|
|
88
|
+
setAccountCredentials('bluesky', accountName, {
|
|
89
|
+
identifier: creds.identifier,
|
|
90
|
+
appPassword: creds.appPassword,
|
|
91
|
+
accessJwt: session.accessJwt,
|
|
92
|
+
refreshJwt: session.refreshJwt,
|
|
93
|
+
did: session.did,
|
|
94
|
+
expiresAt: Date.now() + (90 * 60 * 1000)
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return session.accessJwt;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Refresh Google OAuth token (Calendar/YouTube)
|
|
104
|
+
async function refreshGoogleToken(accountName, creds, service) {
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
109
|
+
body: new URLSearchParams({
|
|
110
|
+
client_id: creds.clientId,
|
|
111
|
+
client_secret: creds.clientSecret,
|
|
112
|
+
refresh_token: creds.refreshToken,
|
|
113
|
+
grant_type: 'refresh_token'
|
|
114
|
+
})
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!response.ok) return null;
|
|
118
|
+
|
|
119
|
+
const tokens = await response.json();
|
|
120
|
+
setAccountCredentials(service, accountName, {
|
|
121
|
+
...creds,
|
|
122
|
+
accessToken: tokens.access_token,
|
|
123
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return tokens.access_token;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Refresh Reddit OAuth token
|
|
133
|
+
async function refreshRedditToken(accountName, creds) {
|
|
134
|
+
try {
|
|
135
|
+
const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
|
|
136
|
+
|
|
137
|
+
const response = await fetch('https://www.reddit.com/api/v1/access_token', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: {
|
|
140
|
+
'Authorization': `Basic ${basicAuth}`,
|
|
141
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
142
|
+
},
|
|
143
|
+
body: new URLSearchParams({
|
|
144
|
+
grant_type: 'refresh_token',
|
|
145
|
+
refresh_token: creds.refreshToken
|
|
146
|
+
})
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!response.ok) return null;
|
|
150
|
+
|
|
151
|
+
const tokens = await response.json();
|
|
152
|
+
setAccountCredentials('reddit', accountName, {
|
|
153
|
+
...creds,
|
|
154
|
+
accessToken: tokens.access_token,
|
|
155
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return tokens.access_token;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Refresh LinkedIn OAuth token
|
|
165
|
+
async function refreshLinkedInToken(accountName, creds) {
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
170
|
+
body: new URLSearchParams({
|
|
171
|
+
grant_type: 'refresh_token',
|
|
172
|
+
refresh_token: creds.refreshToken,
|
|
173
|
+
client_id: creds.clientId,
|
|
174
|
+
client_secret: creds.clientSecret
|
|
175
|
+
})
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!response.ok) return null;
|
|
179
|
+
|
|
180
|
+
const tokens = await response.json();
|
|
181
|
+
setAccountCredentials('linkedin', accountName, {
|
|
182
|
+
...creds,
|
|
183
|
+
accessToken: tokens.access_token,
|
|
184
|
+
refreshToken: tokens.refresh_token || creds.refreshToken,
|
|
185
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return tokens.access_token;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Refresh Fitbit OAuth token
|
|
195
|
+
async function refreshFitbitToken(accountName, creds) {
|
|
196
|
+
try {
|
|
197
|
+
const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
|
|
198
|
+
|
|
199
|
+
const response = await fetch('https://api.fitbit.com/oauth2/token', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: {
|
|
202
|
+
'Authorization': `Basic ${basicAuth}`,
|
|
203
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
204
|
+
},
|
|
205
|
+
body: new URLSearchParams({
|
|
206
|
+
grant_type: 'refresh_token',
|
|
207
|
+
refresh_token: creds.refreshToken
|
|
208
|
+
})
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!response.ok) return null;
|
|
212
|
+
|
|
213
|
+
const tokens = await response.json();
|
|
214
|
+
setAccountCredentials('fitbit', accountName, {
|
|
215
|
+
...creds,
|
|
216
|
+
accessToken: tokens.access_token,
|
|
217
|
+
refreshToken: tokens.refresh_token || creds.refreshToken,
|
|
218
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return tokens.access_token;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Build the full URL for a service request
|
|
228
|
+
function buildUrl(service, accountName, path) {
|
|
229
|
+
const creds = getAccountCredentials(service, accountName);
|
|
230
|
+
|
|
231
|
+
// Handle dynamic URLs
|
|
232
|
+
if (service === 'mastodon' && creds?.instance) {
|
|
233
|
+
return `https://${creds.instance}/${path.replace(/^\//, '')}`;
|
|
234
|
+
}
|
|
235
|
+
if (service === 'jira' && creds?.domain) {
|
|
236
|
+
return `https://${creds.domain}/rest/api/3/${path.replace(/^\//, '')}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const baseUrl = SERVICE_URLS[service];
|
|
240
|
+
if (!baseUrl) return null;
|
|
241
|
+
|
|
242
|
+
return `${baseUrl}/${path.replace(/^\//, '')}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Build headers for a service request
|
|
246
|
+
function buildHeaders(service, token, customHeaders = {}) {
|
|
247
|
+
const headers = {
|
|
248
|
+
'Accept': 'application/json',
|
|
249
|
+
'Content-Type': 'application/json',
|
|
250
|
+
...customHeaders
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (service === 'jira' && token?.email && token?.apiToken) {
|
|
254
|
+
// Jira uses basic auth
|
|
255
|
+
const basicAuth = Buffer.from(`${token.email}:${token.apiToken}`).toString('base64');
|
|
256
|
+
headers['Authorization'] = `Basic ${basicAuth}`;
|
|
257
|
+
} else if (token && typeof token === 'string') {
|
|
258
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Service-specific headers
|
|
262
|
+
if (service === 'github') {
|
|
263
|
+
headers['Accept'] = 'application/vnd.github+json';
|
|
264
|
+
headers['User-Agent'] = 'agentgate-gateway';
|
|
265
|
+
}
|
|
266
|
+
if (service === 'reddit') {
|
|
267
|
+
headers['User-Agent'] = 'agentgate-gateway/1.0';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return headers;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Execute a single queued entry (batch of requests)
|
|
274
|
+
export async function executeQueueEntry(entry) {
|
|
275
|
+
const results = [];
|
|
276
|
+
const { service, account_name, requests } = entry;
|
|
277
|
+
|
|
278
|
+
// Mark as executing
|
|
279
|
+
updateQueueStatus(entry.id, 'executing');
|
|
280
|
+
|
|
281
|
+
for (let i = 0; i < requests.length; i++) {
|
|
282
|
+
const req = requests[i];
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Get fresh token for each request (in case of expiry during batch)
|
|
286
|
+
const token = await getAccessToken(service, account_name);
|
|
287
|
+
if (!token) {
|
|
288
|
+
results.push({
|
|
289
|
+
index: i,
|
|
290
|
+
ok: false,
|
|
291
|
+
error: `Failed to get access token for ${service}/${account_name}`
|
|
292
|
+
});
|
|
293
|
+
updateQueueStatus(entry.id, 'failed', { results });
|
|
294
|
+
return { success: false, results };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Build URL
|
|
298
|
+
const url = buildUrl(service, account_name, req.path);
|
|
299
|
+
if (!url) {
|
|
300
|
+
results.push({
|
|
301
|
+
index: i,
|
|
302
|
+
ok: false,
|
|
303
|
+
error: `Unknown service or invalid configuration: ${service}`
|
|
304
|
+
});
|
|
305
|
+
updateQueueStatus(entry.id, 'failed', { results });
|
|
306
|
+
return { success: false, results };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Build headers
|
|
310
|
+
const headers = buildHeaders(service, token, req.headers);
|
|
311
|
+
|
|
312
|
+
// Make the request
|
|
313
|
+
const fetchOptions = {
|
|
314
|
+
method: req.method,
|
|
315
|
+
headers
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (req.body && ['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
|
|
319
|
+
fetchOptions.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const response = await fetch(url, fetchOptions);
|
|
323
|
+
|
|
324
|
+
// Parse response
|
|
325
|
+
let responseBody;
|
|
326
|
+
const contentType = response.headers.get('content-type') || '';
|
|
327
|
+
if (contentType.includes('application/json')) {
|
|
328
|
+
responseBody = await response.json();
|
|
329
|
+
} else {
|
|
330
|
+
responseBody = await response.text();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const result = {
|
|
334
|
+
index: i,
|
|
335
|
+
ok: response.ok,
|
|
336
|
+
status: response.status,
|
|
337
|
+
body: responseBody
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
results.push(result);
|
|
341
|
+
|
|
342
|
+
// Stop on first failure
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
updateQueueStatus(entry.id, 'failed', { results });
|
|
345
|
+
return { success: false, results };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
} catch (err) {
|
|
349
|
+
results.push({
|
|
350
|
+
index: i,
|
|
351
|
+
ok: false,
|
|
352
|
+
error: err.message
|
|
353
|
+
});
|
|
354
|
+
updateQueueStatus(entry.id, 'failed', { results });
|
|
355
|
+
return { success: false, results };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// All requests succeeded
|
|
360
|
+
updateQueueStatus(entry.id, 'completed', { results });
|
|
361
|
+
return { success: true, results };
|
|
362
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
const BSKY_API = 'https://bsky.social/xrpc';
|
|
6
|
+
|
|
7
|
+
// Service metadata - exported for /api/readme and /api/skill
|
|
8
|
+
export const serviceInfo = {
|
|
9
|
+
key: 'bluesky',
|
|
10
|
+
name: 'Bluesky',
|
|
11
|
+
shortDesc: 'Timeline, posts, profile (DMs blocked)',
|
|
12
|
+
description: 'Bluesky/AT Protocol proxy (DMs blocked)',
|
|
13
|
+
authType: 'app password',
|
|
14
|
+
docs: 'https://docs.bsky.app/docs/api/',
|
|
15
|
+
examples: [
|
|
16
|
+
'GET /api/bluesky/{accountName}/app.bsky.feed.getTimeline',
|
|
17
|
+
'GET /api/bluesky/{accountName}/app.bsky.feed.getAuthorFeed?actor={handle}',
|
|
18
|
+
'GET /api/bluesky/{accountName}/app.bsky.actor.getProfile?actor={handle}'
|
|
19
|
+
]
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Blocked routes - no DMs/chat
|
|
23
|
+
const BLOCKED_PATTERNS = [
|
|
24
|
+
/^chat\./, // all chat.bsky.* endpoints
|
|
25
|
+
/^com\.atproto\.admin/ // admin endpoints
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Get a valid access token, refreshing if needed
|
|
29
|
+
async function getAccessToken(accountName) {
|
|
30
|
+
const creds = getAccountCredentials('bluesky', accountName);
|
|
31
|
+
if (!creds) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If we have an access token and it's not expired, use it
|
|
36
|
+
if (creds.accessJwt && creds.expiresAt && Date.now() < creds.expiresAt) {
|
|
37
|
+
return creds.accessJwt;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Need to create a new session with app password
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(`${BSKY_API}/com.atproto.server.createSession`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
identifier: creds.identifier,
|
|
47
|
+
password: creds.appPassword
|
|
48
|
+
})
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
console.error('Bluesky auth failed:', await response.text());
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const session = await response.json();
|
|
57
|
+
|
|
58
|
+
// Store the new tokens (access token valid for ~2 hours)
|
|
59
|
+
setAccountCredentials('bluesky', accountName, {
|
|
60
|
+
identifier: creds.identifier,
|
|
61
|
+
appPassword: creds.appPassword,
|
|
62
|
+
accessJwt: session.accessJwt,
|
|
63
|
+
refreshJwt: session.refreshJwt,
|
|
64
|
+
did: session.did,
|
|
65
|
+
expiresAt: Date.now() + (90 * 60 * 1000) // 90 minutes to be safe
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return session.accessJwt;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('Bluesky session creation failed:', error);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Proxy GET requests to Bluesky API
|
|
76
|
+
// Route: /api/bluesky/:accountName/*
|
|
77
|
+
router.get('/:accountName/*', async (req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
const { accountName } = req.params;
|
|
80
|
+
const accessToken = await getAccessToken(accountName);
|
|
81
|
+
if (!accessToken) {
|
|
82
|
+
return res.status(401).json({
|
|
83
|
+
error: 'Bluesky account not configured',
|
|
84
|
+
message: `Set up Bluesky account "${accountName}" in the admin UI`
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const path = req.params[0] || '';
|
|
89
|
+
|
|
90
|
+
// Check blocked routes
|
|
91
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
92
|
+
if (pattern.test(path)) {
|
|
93
|
+
return res.status(403).json({
|
|
94
|
+
error: 'Route blocked',
|
|
95
|
+
message: 'This endpoint is blocked for privacy (DMs/chat)'
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const queryString = new URLSearchParams(req.query).toString();
|
|
101
|
+
const url = `${BSKY_API}/${path}${queryString ? '?' + queryString : ''}`;
|
|
102
|
+
|
|
103
|
+
const response = await fetch(url, {
|
|
104
|
+
headers: {
|
|
105
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
106
|
+
'Accept': 'application/json'
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const data = await response.json();
|
|
111
|
+
res.status(response.status).json(data);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
res.status(500).json({ error: 'Bluesky API request failed', message: error.message });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Handle root path for account
|
|
118
|
+
router.get('/:accountName', async (req, res) => {
|
|
119
|
+
res.json({
|
|
120
|
+
service: 'bluesky',
|
|
121
|
+
account: req.params.accountName,
|
|
122
|
+
description: 'Bluesky/AT Protocol proxy (DMs blocked). Append XRPC method after account name.',
|
|
123
|
+
examples: [
|
|
124
|
+
`GET /api/bluesky/${req.params.accountName}/app.bsky.feed.getTimeline`,
|
|
125
|
+
`GET /api/bluesky/${req.params.accountName}/app.bsky.actor.getProfile?actor=handle.bsky.social`
|
|
126
|
+
]
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export default router;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
const GOOGLE_API = 'https://www.googleapis.com/calendar/v3';
|
|
6
|
+
const GOOGLE_AUTH = 'https://oauth2.googleapis.com';
|
|
7
|
+
|
|
8
|
+
// Service metadata - exported for /api/readme and /api/skill
|
|
9
|
+
export const serviceInfo = {
|
|
10
|
+
key: 'calendar',
|
|
11
|
+
name: 'Google Calendar',
|
|
12
|
+
shortDesc: 'Events, calendars',
|
|
13
|
+
description: 'Google Calendar API proxy',
|
|
14
|
+
authType: 'oauth',
|
|
15
|
+
dbKey: 'google_calendar',
|
|
16
|
+
docs: 'https://developers.google.com/calendar/api/v3/reference',
|
|
17
|
+
examples: [
|
|
18
|
+
'GET /api/calendar/{accountName}/users/me/calendarList',
|
|
19
|
+
'GET /api/calendar/{accountName}/calendars/primary/events',
|
|
20
|
+
'GET /api/calendar/{accountName}/calendars/{calendarId}/events?timeMin={ISO8601}&timeMax={ISO8601}'
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Get a valid access token, refreshing if needed
|
|
25
|
+
async function getAccessToken(accountName) {
|
|
26
|
+
const creds = getAccountCredentials('google_calendar', accountName);
|
|
27
|
+
if (!creds) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// If we have an access token and it's not expired, use it
|
|
32
|
+
if (creds.accessToken && creds.expiresAt && Date.now() < creds.expiresAt) {
|
|
33
|
+
return creds.accessToken;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Need to refresh the token
|
|
37
|
+
if (!creds.refreshToken) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(`${GOOGLE_AUTH}/token`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
46
|
+
},
|
|
47
|
+
body: new URLSearchParams({
|
|
48
|
+
client_id: creds.clientId,
|
|
49
|
+
client_secret: creds.clientSecret,
|
|
50
|
+
refresh_token: creds.refreshToken,
|
|
51
|
+
grant_type: 'refresh_token'
|
|
52
|
+
})
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
console.error('Google token refresh failed:', await response.text());
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tokens = await response.json();
|
|
61
|
+
|
|
62
|
+
// Store the new tokens
|
|
63
|
+
setAccountCredentials('google_calendar', accountName, {
|
|
64
|
+
...creds,
|
|
65
|
+
accessToken: tokens.access_token,
|
|
66
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000 // 1 min buffer
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return tokens.access_token;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error('Google token refresh failed:', error);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Proxy GET requests to Google Calendar API
|
|
77
|
+
// Route: /api/calendar/:accountName/*
|
|
78
|
+
router.get('/:accountName/*', async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const { accountName } = req.params;
|
|
81
|
+
const accessToken = await getAccessToken(accountName);
|
|
82
|
+
if (!accessToken) {
|
|
83
|
+
return res.status(401).json({
|
|
84
|
+
error: 'Google Calendar account not configured',
|
|
85
|
+
message: `Set up Google Calendar account "${accountName}" in the admin UI`
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const path = req.params[0] || '';
|
|
90
|
+
const queryString = new URLSearchParams(req.query).toString();
|
|
91
|
+
const url = `${GOOGLE_API}/${path}${queryString ? '?' + queryString : ''}`;
|
|
92
|
+
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
headers: {
|
|
95
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
96
|
+
'Accept': 'application/json'
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const data = await response.json();
|
|
101
|
+
res.status(response.status).json(data);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
res.status(500).json({ error: 'Google Calendar API request failed', message: error.message });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Handle root path for account
|
|
108
|
+
router.get('/:accountName', async (req, res) => {
|
|
109
|
+
res.json({
|
|
110
|
+
service: 'google_calendar',
|
|
111
|
+
account: req.params.accountName,
|
|
112
|
+
description: 'Google Calendar API proxy. Append API path after account name.',
|
|
113
|
+
examples: [
|
|
114
|
+
`GET /api/calendar/${req.params.accountName}/users/me/calendarList`,
|
|
115
|
+
`GET /api/calendar/${req.params.accountName}/calendars/primary/events`
|
|
116
|
+
]
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export default router;
|