neoagent 2.3.0 → 2.3.1-beta.2

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,487 @@
1
+ 'use strict';
2
+
3
+ const { describeEnvStatus, resolveSpotifyOAuthConfig } = require('../env');
4
+ const { appendQuery, createOAuthProvider } = require('../oauth_provider');
5
+
6
+ const SPOTIFY_APPS = [
7
+ {
8
+ id: 'spotify',
9
+ label: 'Spotify',
10
+ description: 'Connect Spotify for playback status, search, and playback controls.',
11
+ scopes: [
12
+ 'user-read-email',
13
+ 'user-read-private',
14
+ 'user-read-playback-state',
15
+ 'user-modify-playback-state',
16
+ 'user-read-currently-playing',
17
+ 'user-read-recently-played',
18
+ ],
19
+ },
20
+ ];
21
+
22
+ const spotifyToolDefinitions = [
23
+ {
24
+ appId: 'spotify',
25
+ name: 'spotify_get_current_playback',
26
+ access: 'read',
27
+ description: 'Get current playback state for the connected Spotify account.',
28
+ parameters: { type: 'object', properties: {} },
29
+ },
30
+ {
31
+ appId: 'spotify',
32
+ name: 'spotify_get_recently_played',
33
+ access: 'read',
34
+ description: 'Get recently played tracks for the connected Spotify account.',
35
+ parameters: {
36
+ type: 'object',
37
+ properties: {
38
+ limit: { type: 'number', description: 'Maximum number of tracks (1-50), default 20.' },
39
+ },
40
+ },
41
+ },
42
+ {
43
+ appId: 'spotify',
44
+ name: 'spotify_search',
45
+ access: 'read',
46
+ description: 'Search Spotify catalog for tracks, albums, artists, or playlists.',
47
+ parameters: {
48
+ type: 'object',
49
+ properties: {
50
+ query: { type: 'string', description: 'Search query text.' },
51
+ type: { type: 'string', description: 'Comma-separated types: track,artist,album,playlist. Default track.' },
52
+ limit: { type: 'number', description: 'Maximum items per type (1-50), default 10.' },
53
+ market: { type: 'string', description: 'Optional market code like US.' },
54
+ },
55
+ required: ['query'],
56
+ },
57
+ },
58
+ {
59
+ appId: 'spotify',
60
+ name: 'spotify_control_playback',
61
+ access: 'write',
62
+ description: 'Control Spotify playback: play, pause, next, previous, seek, set_volume, shuffle, or repeat.',
63
+ parameters: {
64
+ type: 'object',
65
+ properties: {
66
+ action: {
67
+ type: 'string',
68
+ description: 'Playback action: play, pause, next, previous, seek, set_volume, shuffle, repeat.',
69
+ },
70
+ device_id: { type: 'string', description: 'Optional target device ID.' },
71
+ context_uri: { type: 'string', description: 'Optional context URI for play action.' },
72
+ uris: { type: 'array', items: { type: 'string' }, description: 'Optional track URIs for play action.' },
73
+ position_ms: { type: 'number', description: 'Optional playback offset in ms for play/seek.' },
74
+ volume_percent: { type: 'number', description: 'Volume 0-100 for set_volume action.' },
75
+ state: { type: 'boolean', description: 'Shuffle state for shuffle action.' },
76
+ mode: { type: 'string', description: 'Repeat mode for repeat action: track, context, off.' },
77
+ },
78
+ required: ['action'],
79
+ },
80
+ },
81
+ {
82
+ appId: 'spotify',
83
+ name: 'spotify_api_request',
84
+ access: 'dynamic_http_method',
85
+ description: 'Make an authenticated Spotify Web API request for advanced operations.',
86
+ parameters: {
87
+ type: 'object',
88
+ properties: {
89
+ method: { type: 'string', description: 'HTTP method.' },
90
+ path: { type: 'string', description: 'Spotify API path such as /v1/me/player/devices.' },
91
+ query: { type: 'object', description: 'Optional query parameters.' },
92
+ body: { type: 'object', description: 'Optional JSON request body.' },
93
+ },
94
+ required: ['method', 'path'],
95
+ },
96
+ },
97
+ ];
98
+
99
+ function requireText(value, label) {
100
+ const text = String(value || '').trim();
101
+ if (!text) {
102
+ throw new Error(`${label} is required.`);
103
+ }
104
+ return text;
105
+ }
106
+
107
+ function spotifyUrl(path, query) {
108
+ const rawPath = String(path || '').trim();
109
+ const url = new URL(
110
+ rawPath.startsWith('http')
111
+ ? rawPath
112
+ : `https://api.spotify.com${rawPath.startsWith('/') ? '' : '/'}${rawPath}`,
113
+ );
114
+ if (url.hostname !== 'api.spotify.com') {
115
+ throw new Error('Spotify API request URL must target api.spotify.com.');
116
+ }
117
+ for (const [key, value] of Object.entries(query || {})) {
118
+ if (value === undefined || value === null) continue;
119
+ url.searchParams.set(key, String(value));
120
+ }
121
+ return url.toString();
122
+ }
123
+
124
+ async function refreshSpotifyAccessToken(config, credentials) {
125
+ const refreshToken = String(credentials?.refresh_token || '').trim();
126
+ if (!refreshToken) {
127
+ return credentials;
128
+ }
129
+ const basic = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
130
+ const response = await fetch('https://accounts.spotify.com/api/token', {
131
+ method: 'POST',
132
+ headers: {
133
+ Authorization: `Basic ${basic}`,
134
+ 'Content-Type': 'application/x-www-form-urlencoded',
135
+ Accept: 'application/json',
136
+ },
137
+ body: new URLSearchParams({
138
+ grant_type: 'refresh_token',
139
+ refresh_token: refreshToken,
140
+ }).toString(),
141
+ });
142
+ const data = await response.json().catch(() => ({}));
143
+ if (!response.ok) {
144
+ const message = data?.error_description || data?.error || `${response.status} ${response.statusText}`;
145
+ throw new Error(`Spotify token refresh failed: ${message}`);
146
+ }
147
+ const expiresIn = Number(data?.expires_in) || 3600;
148
+ return {
149
+ ...credentials,
150
+ access_token: data?.access_token || credentials.access_token,
151
+ refresh_token: data?.refresh_token || refreshToken,
152
+ token_type: data?.token_type || credentials.token_type || 'Bearer',
153
+ scope: data?.scope || credentials.scope || '',
154
+ expires_in: expiresIn,
155
+ expires_at: new Date(Date.now() + expiresIn * 1000).toISOString(),
156
+ };
157
+ }
158
+
159
+ async function ensureSpotifyAccessToken(config, credentials) {
160
+ const accessToken = String(credentials?.access_token || '').trim();
161
+ if (!accessToken) {
162
+ throw new Error('Spotify access token is missing. Reconnect this integration account.');
163
+ }
164
+
165
+ const expiresAt = Date.parse(String(credentials?.expires_at || ''));
166
+ const isNearExpiry = Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60 * 1000;
167
+ if (isNearExpiry && credentials?.refresh_token) {
168
+ return refreshSpotifyAccessToken(config, credentials);
169
+ }
170
+ return credentials;
171
+ }
172
+
173
+ async function spotifyRequest(config, credentials, { method = 'GET', path, query, body }) {
174
+ let nextCredentials = await ensureSpotifyAccessToken(config, credentials);
175
+ const tokenType = String(nextCredentials?.token_type || 'Bearer').trim() || 'Bearer';
176
+
177
+ const performRequest = async (tokenCreds) => {
178
+ const tokenType = String(tokenCreds?.token_type || 'Bearer').trim() || 'Bearer';
179
+ const response = await fetch(spotifyUrl(path, query), {
180
+ method: String(method || 'GET').toUpperCase(),
181
+ headers: {
182
+ Authorization: `${tokenType} ${tokenCreds.access_token}`,
183
+ Accept: 'application/json',
184
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
185
+ },
186
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
187
+ });
188
+
189
+ if (response.status === 204) {
190
+ return { ok: true, data: null, response };
191
+ }
192
+
193
+ const text = await response.text();
194
+ let parsed = null;
195
+ try {
196
+ parsed = text ? JSON.parse(text) : null;
197
+ } catch {
198
+ parsed = null;
199
+ }
200
+ return { ok: response.ok, data: parsed ?? text, response };
201
+ };
202
+
203
+ let result = await performRequest(nextCredentials);
204
+ if (!result.ok && result.response.status === 401 && nextCredentials.refresh_token) {
205
+ nextCredentials = await refreshSpotifyAccessToken(config, nextCredentials);
206
+ result = await performRequest(nextCredentials);
207
+ }
208
+
209
+ if (!result.ok) {
210
+ const message =
211
+ (result.data && typeof result.data === 'object' && (result.data.error?.message || result.data.error_description || result.data.error)) ||
212
+ `${result.response.status} ${result.response.statusText}`;
213
+ throw new Error(`Spotify request failed: ${String(message).trim()}`);
214
+ }
215
+
216
+ return {
217
+ data: result.data,
218
+ credentials: nextCredentials,
219
+ };
220
+ }
221
+
222
+ async function executeSpotifyTool(toolName, args, { credentials }) {
223
+ const config = resolveSpotifyOAuthConfig();
224
+ switch (toolName) {
225
+ case 'spotify_get_current_playback': {
226
+ const { data, credentials: updated } = await spotifyRequest(config, credentials, {
227
+ path: '/v1/me/player',
228
+ });
229
+ return { result: data || { is_playing: false }, credentials: updated };
230
+ }
231
+ case 'spotify_get_recently_played': {
232
+ const limit = Math.max(1, Math.min(Number(args.limit) || 20, 50));
233
+ const { data, credentials: updated } = await spotifyRequest(config, credentials, {
234
+ path: '/v1/me/player/recently-played',
235
+ query: { limit },
236
+ });
237
+ return { result: data, credentials: updated };
238
+ }
239
+ case 'spotify_search': {
240
+ const queryText = requireText(args.query, 'query');
241
+ const types = String(args.type || 'track')
242
+ .split(',')
243
+ .map((entry) => entry.trim().toLowerCase())
244
+ .filter(Boolean)
245
+ .filter((entry) => ['track', 'artist', 'album', 'playlist'].includes(entry));
246
+ const type = types.length > 0 ? types.join(',') : 'track';
247
+ const limit = Math.max(1, Math.min(Number(args.limit) || 10, 50));
248
+ const market = String(args.market || '').trim().toUpperCase();
249
+ const { data, credentials: updated } = await spotifyRequest(config, credentials, {
250
+ path: '/v1/search',
251
+ query: {
252
+ q: queryText,
253
+ type,
254
+ limit,
255
+ ...(market ? { market } : {}),
256
+ },
257
+ });
258
+ return { result: data, credentials: updated };
259
+ }
260
+ case 'spotify_control_playback': {
261
+ const action = String(args.action || '').trim().toLowerCase();
262
+ const deviceId = String(args.device_id || '').trim();
263
+ const query = deviceId ? { device_id: deviceId } : undefined;
264
+
265
+ let request;
266
+ switch (action) {
267
+ case 'play':
268
+ request = {
269
+ method: 'PUT',
270
+ path: '/v1/me/player/play',
271
+ query,
272
+ body: {
273
+ ...(String(args.context_uri || '').trim() ? { context_uri: String(args.context_uri).trim() } : {}),
274
+ ...(Array.isArray(args.uris) ? { uris: args.uris } : {}),
275
+ ...(Number.isFinite(Number(args.position_ms)) ? { position_ms: Number(args.position_ms) } : {}),
276
+ },
277
+ };
278
+ break;
279
+ case 'pause':
280
+ request = { method: 'PUT', path: '/v1/me/player/pause', query };
281
+ break;
282
+ case 'next':
283
+ request = { method: 'POST', path: '/v1/me/player/next', query };
284
+ break;
285
+ case 'previous':
286
+ request = { method: 'POST', path: '/v1/me/player/previous', query };
287
+ break;
288
+ case 'seek': {
289
+ const positionMs = Number(args.position_ms);
290
+ if (!Number.isFinite(positionMs) || positionMs < 0) {
291
+ throw new Error('position_ms must be a non-negative number for seek action.');
292
+ }
293
+ request = {
294
+ method: 'PUT',
295
+ path: '/v1/me/player/seek',
296
+ query: {
297
+ ...(query || {}),
298
+ position_ms: Math.floor(positionMs),
299
+ },
300
+ };
301
+ break;
302
+ }
303
+ case 'set_volume': {
304
+ const volume = Number(args.volume_percent);
305
+ if (!Number.isFinite(volume) || volume < 0 || volume > 100) {
306
+ throw new Error('volume_percent must be between 0 and 100 for set_volume action.');
307
+ }
308
+ request = {
309
+ method: 'PUT',
310
+ path: '/v1/me/player/volume',
311
+ query: {
312
+ ...(query || {}),
313
+ volume_percent: Math.round(volume),
314
+ },
315
+ };
316
+ break;
317
+ }
318
+ case 'shuffle': {
319
+ if (typeof args.state !== 'boolean') {
320
+ throw new Error('state must be true or false for shuffle action.');
321
+ }
322
+ request = {
323
+ method: 'PUT',
324
+ path: '/v1/me/player/shuffle',
325
+ query: {
326
+ ...(query || {}),
327
+ state: args.state,
328
+ },
329
+ };
330
+ break;
331
+ }
332
+ case 'repeat': {
333
+ const mode = String(args.mode || '').trim().toLowerCase();
334
+ if (!['track', 'context', 'off'].includes(mode)) {
335
+ throw new Error('mode must be one of track, context, or off for repeat action.');
336
+ }
337
+ request = {
338
+ method: 'PUT',
339
+ path: '/v1/me/player/repeat',
340
+ query: {
341
+ ...(query || {}),
342
+ state: mode,
343
+ },
344
+ };
345
+ break;
346
+ }
347
+ default:
348
+ throw new Error('Unsupported action. Use play, pause, next, previous, seek, set_volume, shuffle, or repeat.');
349
+ }
350
+
351
+ const { data, credentials: updated } = await spotifyRequest(config, credentials, request);
352
+ return {
353
+ result: {
354
+ action,
355
+ ok: true,
356
+ response: data,
357
+ },
358
+ credentials: updated,
359
+ };
360
+ }
361
+ case 'spotify_api_request': {
362
+ const { data, credentials: updated } = await spotifyRequest(config, credentials, {
363
+ method: args.method,
364
+ path: requireText(args.path, 'path'),
365
+ query: args.query,
366
+ body: args.body,
367
+ });
368
+ return { result: data, credentials: updated };
369
+ }
370
+ default:
371
+ return null;
372
+ }
373
+ }
374
+
375
+ async function fetchSpotifyProfile(accessToken) {
376
+ const response = await fetch('https://api.spotify.com/v1/me', {
377
+ method: 'GET',
378
+ headers: {
379
+ Authorization: `Bearer ${accessToken}`,
380
+ Accept: 'application/json',
381
+ },
382
+ });
383
+ const payload = await response.json().catch(() => ({}));
384
+ if (!response.ok) {
385
+ const message = payload?.error?.message || payload?.error || `${response.status} ${response.statusText}`;
386
+ throw new Error(`Spotify profile request failed: ${message}`);
387
+ }
388
+ return payload;
389
+ }
390
+
391
+ function createSpotifyProvider() {
392
+ return createOAuthProvider({
393
+ key: 'spotify',
394
+ label: 'Spotify',
395
+ description: 'Official Spotify account integration for music search and playback control.',
396
+ icon: 'spotify',
397
+ apps: SPOTIFY_APPS,
398
+ toolDefinitions: spotifyToolDefinitions,
399
+ connectPrompt:
400
+ 'Connect Spotify to allow playback-aware automations, track search, and playback controls from the agent.',
401
+ getEnvStatus() {
402
+ return describeEnvStatus(resolveSpotifyOAuthConfig(), {
403
+ label: 'Spotify',
404
+ });
405
+ },
406
+ async beginOAuth({ state, app }) {
407
+ const config = resolveSpotifyOAuthConfig();
408
+ return {
409
+ url: appendQuery('https://accounts.spotify.com/authorize', {
410
+ response_type: 'code',
411
+ client_id: config.clientId,
412
+ redirect_uri: config.redirectUri,
413
+ scope: app.scopes.join(' '),
414
+ state,
415
+ show_dialog: 'true',
416
+ }),
417
+ appId: app.id,
418
+ };
419
+ },
420
+ async finishOAuth({ code, app }) {
421
+ const config = resolveSpotifyOAuthConfig();
422
+ const basic = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
423
+ const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
424
+ method: 'POST',
425
+ headers: {
426
+ Authorization: `Basic ${basic}`,
427
+ 'Content-Type': 'application/x-www-form-urlencoded',
428
+ Accept: 'application/json',
429
+ },
430
+ body: new URLSearchParams({
431
+ grant_type: 'authorization_code',
432
+ code,
433
+ redirect_uri: config.redirectUri,
434
+ }).toString(),
435
+ });
436
+ const token = await tokenResponse.json().catch(() => ({}));
437
+ if (!tokenResponse.ok) {
438
+ const message = token?.error_description || token?.error || `${tokenResponse.status} ${tokenResponse.statusText}`;
439
+ throw new Error(`Spotify OAuth token exchange failed: ${message}`);
440
+ }
441
+
442
+ const accessToken = String(token?.access_token || '').trim();
443
+ if (!accessToken) {
444
+ throw new Error('Spotify OAuth did not return an access token.');
445
+ }
446
+ const refreshToken = String(token?.refresh_token || '').trim();
447
+ if (!refreshToken) {
448
+ throw new Error('Spotify OAuth did not return a refresh token.');
449
+ }
450
+
451
+ const profile = await fetchSpotifyProfile(accessToken);
452
+ const accountEmail =
453
+ String(profile?.email || '').trim() ||
454
+ (String(profile?.id || '').trim() ? `spotify:${String(profile.id).trim()}` : 'spotify_user');
455
+
456
+ const expiresIn = Number(token?.expires_in) || 3600;
457
+ return {
458
+ appId: app.id,
459
+ accountEmail,
460
+ credentials: {
461
+ access_token: accessToken,
462
+ refresh_token: refreshToken,
463
+ token_type: token?.token_type || 'Bearer',
464
+ scope: token?.scope || app.scopes.join(' '),
465
+ expires_in: expiresIn,
466
+ expires_at: new Date(Date.now() + expiresIn * 1000).toISOString(),
467
+ },
468
+ scopes: String(token?.scope || app.scopes.join(' '))
469
+ .split(/\s+/)
470
+ .map((scope) => scope.trim())
471
+ .filter(Boolean),
472
+ metadata: {
473
+ spotifyUserId: profile?.id || null,
474
+ displayName: profile?.display_name || null,
475
+ email: profile?.email || null,
476
+ country: profile?.country || null,
477
+ product: profile?.product || null,
478
+ },
479
+ };
480
+ },
481
+ executeTool: executeSpotifyTool,
482
+ });
483
+ }
484
+
485
+ module.exports = {
486
+ createSpotifyProvider,
487
+ };