diff --git a/public/icons/plivo.svg b/public/icons/plivo.svg new file mode 100644 index 0000000..70005d3 --- /dev/null +++ b/public/icons/plivo.svg @@ -0,0 +1 @@ + diff --git a/src/index.js b/src/index.js index ad28fff..021b6c6 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,7 @@ import fitbitRoutes from './routes/fitbit.js'; import braveRoutes from './routes/brave.js'; import googleSearchRoutes from './routes/google-search.js'; import homeassistantRoutes from './routes/homeassistant.js'; +import plivoRoutes from './routes/plivo.js'; import queueRoutes from './routes/queue.js'; import agentsRoutes from './routes/agents.js'; import mementoRoutes from './routes/memento.js'; @@ -80,6 +81,7 @@ app.use('/api/fitbit', apiKeyAuth, serviceAccessCheck('fitbit'), writeProxy('fit app.use('/api/brave', apiKeyAuth, serviceAccessCheck('brave'), writeProxy('brave'), braveRoutes); app.use('/api/google_search', apiKeyAuth, serviceAccessCheck('google_search'), writeProxy('google_search'), googleSearchRoutes); app.use('/api/homeassistant', apiKeyAuth, serviceAccessCheck('homeassistant'), writeProxy('homeassistant'), homeassistantRoutes); +app.use('/api/plivo', apiKeyAuth, serviceAccessCheck('plivo'), writeProxy('plivo'), plivoRoutes); // Service access management - admin API (requires auth) app.use('/api/services', apiKeyAuth, servicesRoutes); diff --git a/src/lib/queueExecutor.js b/src/lib/queueExecutor.js index 0f33f4f..4ea6ed0 100644 --- a/src/lib/queueExecutor.js +++ b/src/lib/queueExecutor.js @@ -14,7 +14,8 @@ const SERVICE_URLS = { linkedin: 'https://api.linkedin.com/v2', jira: null, // Dynamic: https://{domain}/rest/api/3 fitbit: 'https://api.fitbit.com', - homeassistant: null // Dynamic: {host}/api + homeassistant: null, // Dynamic: {host}/api + plivo: null // Dynamic: https://api.plivo.com/v1/Account/{auth_id} }; // Get access token for a service, refreshing if needed @@ -55,6 +56,9 @@ export async function getAccessToken(service, accountName) { case 'homeassistant': return creds.token || null; + case 'plivo': + return creds; + default: return null; } @@ -245,6 +249,9 @@ export function buildUrl(service, accountName, path) { const host = creds.host.replace(/\/+$/, ''); return `${host}/api/${path.replace(/^\//, '')}`; } + if (service === 'plivo' && creds?.authId) { + return `https://api.plivo.com/v1/Account/${creds.authId}/${path.replace(/^\//, '')}`; + } const baseUrl = SERVICE_URLS[service]; if (!baseUrl) return null; @@ -260,7 +267,10 @@ export function buildHeaders(service, token, customHeaders = {}) { 'Content-Type': 'application/json' }; - if (service === 'jira' && token?.email && token?.apiToken) { + if (service === 'plivo' && token?.authId && token?.authToken) { + const basicAuth = Buffer.from(`${token.authId}:${token.authToken}`).toString('base64'); + defaults['Authorization'] = `Basic ${basicAuth}`; + } else if (service === 'jira' && token?.email && token?.apiToken) { const basicAuth = Buffer.from(`${token.email}:${token.apiToken}`).toString('base64'); defaults['Authorization'] = `Basic ${basicAuth}`; } else if (token && typeof token === 'string') { diff --git a/src/lib/serviceRegistry.js b/src/lib/serviceRegistry.js index 972c83d..c61713c 100644 --- a/src/lib/serviceRegistry.js +++ b/src/lib/serviceRegistry.js @@ -10,6 +10,7 @@ import { serviceInfo as fitbitInfo, readService as fitbitRead } from '../routes/ import { serviceInfo as braveInfo, readService as braveRead } from '../routes/brave.js'; import { serviceInfo as googleSearchInfo, readService as googleSearchRead } from '../routes/google-search.js'; import { serviceInfo as homeassistantInfo, readService as homeassistantRead } from '../routes/homeassistant.js'; +import { serviceInfo as plivoInfo, readService as plivoRead } from '../routes/plivo.js'; // Aggregate service metadata from all routes const SERVICE_REGISTRY = { @@ -24,7 +25,8 @@ const SERVICE_REGISTRY = { [fitbitInfo.key]: fitbitInfo, [braveInfo.key]: braveInfo, [googleSearchInfo.key]: googleSearchInfo, - [homeassistantInfo.key]: homeassistantInfo + [homeassistantInfo.key]: homeassistantInfo, + [plivoInfo.key]: plivoInfo }; /** @@ -50,7 +52,8 @@ export const SERVICE_READERS = { [fitbitInfo.key]: fitbitRead, [braveInfo.key]: braveRead, [googleSearchInfo.key]: googleSearchRead, - [homeassistantInfo.key]: homeassistantRead + [homeassistantInfo.key]: homeassistantRead, + [plivoInfo.key]: plivoRead }; // Category mapping for MCP tool registration @@ -59,6 +62,7 @@ export const SERVICE_CATEGORIES = { social: { name: 'Social', description: 'Social networks — posts, profiles, timelines', services: ['bluesky', 'mastodon', 'reddit', 'linkedin'], hasWrite: true }, code: { name: 'Code', description: 'Code repos, issues, PRs, projects', services: ['github', 'jira'], hasWrite: true }, personal: { name: 'Personal', description: 'Health, calendar, and media', services: ['fitbit', 'calendar', 'google_calendar', 'youtube'], hasWrite: true }, + messaging: { name: 'Messaging', description: 'SMS and messaging services', services: ['plivo'], hasWrite: true }, iot: { name: 'IoT', description: 'Smart home and IoT devices', services: ['homeassistant'], hasWrite: true } }; diff --git a/src/routes/plivo.js b/src/routes/plivo.js new file mode 100644 index 0000000..d5f56df --- /dev/null +++ b/src/routes/plivo.js @@ -0,0 +1,101 @@ +import { Router } from 'express'; +import { getAccountCredentials } from '../lib/db.js'; + +const router = Router(); +const PLIVO_API = 'https://api.plivo.com/v1'; + +// Service metadata - exported for /api/agent_start_here and /api/skill +export const serviceInfo = { + key: 'plivo', + name: 'Plivo', + shortDesc: 'SMS messaging via Plivo', + description: 'Plivo SMS API proxy for sending and reading messages', + authType: 'basic', + authMethods: ['basic'], + docs: 'https://www.plivo.com/docs/sms/', + examples: [ + 'GET /api/plivo/{accountName}/messages', + 'GET /api/plivo/{accountName}/messages/{messageUuid}', + 'GET /api/plivo/{accountName}/account' + ] +}; + +// Core read function - used by both Express routes and MCP +export async function readService(accountName, path, { query = {}, raw: _raw = false } = {}) { + const creds = getAccountCredentials('plivo', accountName); + if (!creds?.authId || !creds?.authToken) { + return { status: 401, data: { error: 'Plivo credentials not configured', hint: `Configure Auth ID and Auth Token for account "${accountName}" in the AgentGate UI` } }; + } + + const basicAuth = Buffer.from(`${creds.authId}:${creds.authToken}`).toString('base64'); + const queryString = new URLSearchParams(query).toString(); + const url = `${PLIVO_API}/Account/${creds.authId}/${path}${queryString ? '?' + queryString : ''}`; + + const response = await fetch(url, { + headers: { + 'Authorization': `Basic ${basicAuth}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + return { status: response.status, data }; +} + +// List messages +router.get('/:accountName/messages', async (req, res) => { + try { + const result = await readService(req.params.accountName, 'Message/', { query: req.query }); + res.status(result.status).json(result.data); + } catch (error) { + res.status(500).json({ error: 'Plivo API request failed', message: error.message }); + } +}); + +// Get single message +router.get('/:accountName/messages/:messageUuid', async (req, res) => { + try { + const result = await readService(req.params.accountName, `Message/${req.params.messageUuid}/`); + res.status(result.status).json(result.data); + } catch (error) { + res.status(500).json({ error: 'Plivo API request failed', message: error.message }); + } +}); + +// Account info endpoint +router.get('/:accountName/account', async (req, res) => { + try { + const result = await readService(req.params.accountName, ''); + res.status(result.status).json(result.data); + } catch (error) { + res.status(500).json({ error: 'Plivo API request failed', message: error.message }); + } +}); + +// Wildcard GET - proxy any other Plivo API path +router.get('/:accountName/*', async (req, res) => { + try { + const path = req.params[0]; + const result = await readService(req.params.accountName, path, { query: req.query }); + res.status(result.status).json(result.data); + } catch (error) { + res.status(500).json({ error: 'Plivo API request failed', message: error.message }); + } +}); + +// Account info +router.get('/:accountName', async (req, res) => { + res.json({ + service: 'plivo', + account: req.params.accountName, + description: 'Plivo SMS API proxy. Send and read SMS messages.', + examples: [ + `GET /api/plivo/${req.params.accountName}/messages`, + `GET /api/plivo/${req.params.accountName}/messages/{messageUuid}`, + `GET /api/plivo/${req.params.accountName}/account` + ], + docs: 'https://www.plivo.com/docs/sms/' + }); +}); + +export default router; diff --git a/src/routes/ui/plivo.js b/src/routes/ui/plivo.js new file mode 100644 index 0000000..057b926 --- /dev/null +++ b/src/routes/ui/plivo.js @@ -0,0 +1,63 @@ +import { setAccountCredentials, deleteAccount } from '../../lib/db.js'; +import { escapeHtml } from './shared.js'; + +export function registerRoutes(router) { + router.post('/plivo/setup', (req, res) => { + const { accountName, authId, authToken } = req.body; + if (!accountName || !authId || !authToken) { + return res.status(400).send('Account name, Auth ID, and Auth Token required'); + } + setAccountCredentials('plivo', accountName, { authId, authToken }); + res.redirect('/ui'); + }); + + router.post('/plivo/delete', (req, res) => { + const { accountName } = req.body; + deleteAccount('plivo', accountName); + res.redirect('/ui'); + }); +} + +export function renderCard(accounts, _baseUrl) { + const serviceAccounts = accounts.filter(a => a.service === 'plivo'); + + const renderAccounts = () => { + if (serviceAccounts.length === 0) return ''; + return serviceAccounts.map(acc => ` +
+ ${escapeHtml(acc.name)} +
+ + +
+
+ `).join(''); + }; + + return ` +
+
+ Plivo +

Plivo

+
+ ${renderAccounts()} +
+ Add Plivo Account +
+

Get your Auth ID and Auth Token from the Plivo Console

+
+ + + + + + + +
+
+
+
`; +} + +export const serviceName = 'plivo'; +export const displayName = 'Plivo'; diff --git a/src/routes/ui/service-detail.js b/src/routes/ui/service-detail.js index b3f2168..6e323c5 100644 --- a/src/routes/ui/service-detail.js +++ b/src/routes/ui/service-detail.js @@ -187,7 +187,8 @@ function getServiceIcon(service) { jira: '/public/icons/jira.svg', fitbit: '/public/icons/fitbit.svg', brave: '/public/icons/brave.svg', - google_search: '/public/icons/google-search.svg' + google_search: '/public/icons/google-search.svg', + plivo: '/public/icons/plivo.svg' }; return icons[service] || '/public/favicon.svg'; } @@ -326,6 +327,17 @@ function getServiceFormFields(serviceName) {

Set up at Programmable Search Engine

`, + plivo: ` +
+ + +
+
+ + +

Get your credentials from the Plivo Console

+
`, + homeassistant: `
diff --git a/src/routes/ui/services.js b/src/routes/ui/services.js index 1e3d0b4..991efa5 100644 --- a/src/routes/ui/services.js +++ b/src/routes/ui/services.js @@ -22,6 +22,7 @@ import * as fitbit from './fitbit.js'; import * as brave from './brave.js'; import * as googleSearch from './google-search.js'; import * as homeassistant from './homeassistant.js'; +import * as plivo from './plivo.js'; // Export all implemented services in display order export const services = [ @@ -36,7 +37,8 @@ export const services = [ linkedin, brave, googleSearch, - homeassistant + homeassistant, + plivo ]; // Full service catalog with categories — includes both implemented and coming-soon services @@ -78,6 +80,12 @@ export const catalog = [ { id: 'stripe', name: 'Stripe', icon: '💳', implemented: false } ] }, + { + category: 'Messaging', + services: [ + { id: 'plivo', name: 'Plivo', icon: '💬', implemented: true } + ] + }, { category: 'IoT & Smart Home', services: [ @@ -207,7 +215,8 @@ function getServiceIcon(service) { fitbit: '/public/icons/fitbit.svg', brave: '/public/icons/brave.svg', google_search: '/public/icons/google-search.svg', - homeassistant: '/public/icons/homeassistant.svg' + homeassistant: '/public/icons/homeassistant.svg', + plivo: '/public/icons/plivo.svg' }; return icons[service] || '/public/favicon.svg'; } diff --git a/src/services/queueService.js b/src/services/queueService.js index fb08bc7..6268381 100644 --- a/src/services/queueService.js +++ b/src/services/queueService.js @@ -19,7 +19,7 @@ import { executeQueueEntry } from '../lib/queueExecutor.js'; import { notifyAgentQueueWarning } from '../lib/agentNotifier.js'; // Valid services that support write operations -const VALID_SERVICES = ['github', 'bluesky', 'reddit', 'mastodon', 'calendar', 'google_calendar', 'youtube', 'linkedin', 'jira', 'fitbit', 'homeassistant']; +const VALID_SERVICES = ['github', 'bluesky', 'reddit', 'mastodon', 'calendar', 'google_calendar', 'youtube', 'linkedin', 'jira', 'fitbit', 'homeassistant', 'plivo']; // Valid HTTP methods for write operations const VALID_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; diff --git a/tests/plivo.test.js b/tests/plivo.test.js new file mode 100644 index 0000000..b07da2c --- /dev/null +++ b/tests/plivo.test.js @@ -0,0 +1,125 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../src/lib/db.js', () => ({ + getAccountCredentials: jest.fn() +})); + +const { getAccountCredentials } = await import('../src/lib/db.js'); +const { serviceInfo, readService } = await import('../src/routes/plivo.js'); + +describe('plivo service', () => { + beforeEach(() => { + jest.restoreAllMocks(); + delete globalThis.fetch; + }); + + describe('serviceInfo', () => { + it('should have correct structure', () => { + expect(serviceInfo.key).toBe('plivo'); + expect(serviceInfo.authType).toBe('basic'); + expect(serviceInfo.authMethods).toEqual(['basic']); + expect(serviceInfo.name).toBe('Plivo'); + expect(serviceInfo.examples).toBeDefined(); + expect(serviceInfo.examples.length).toBeGreaterThan(0); + }); + }); + + describe('readService', () => { + it('should return 401 when credentials are missing', async () => { + getAccountCredentials.mockReturnValue(null); + const result = await readService('test', 'Message/'); + expect(result.status).toBe(401); + expect(result.data.error).toContain('not configured'); + }); + + it('should return 401 when authId is missing', async () => { + getAccountCredentials.mockReturnValue({ authToken: 'token123' }); + const result = await readService('test', 'Message/'); + expect(result.status).toBe(401); + }); + + it('should return 401 when authToken is missing', async () => { + getAccountCredentials.mockReturnValue({ authId: 'id123' }); + const result = await readService('test', 'Message/'); + expect(result.status).toBe(401); + }); + + it('should fetch messages with basic auth', async () => { + const mockMessages = { objects: [{ message_uuid: 'abc123', from_number: '+1234567890' }] }; + getAccountCredentials.mockReturnValue({ authId: 'MYAUTHID', authToken: 'MYAUTHTOKEN' }); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockMessages) + }); + + const result = await readService('test', 'Message/'); + expect(result.status).toBe(200); + expect(result.data).toEqual(mockMessages); + + const expectedAuth = Buffer.from('MYAUTHID:MYAUTHTOKEN').toString('base64'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.plivo.com/v1/Account/MYAUTHID/Message/', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': `Basic ${expectedAuth}` + }) + }) + ); + }); + + it('should fetch single message by uuid', async () => { + const mockMessage = { message_uuid: 'abc123', from_number: '+1234567890', text: 'Hello' }; + getAccountCredentials.mockReturnValue({ authId: 'MYAUTHID', authToken: 'MYAUTHTOKEN' }); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockMessage) + }); + + const result = await readService('test', 'Message/abc123/'); + expect(result.status).toBe(200); + expect(result.data.message_uuid).toBe('abc123'); + }); + + it('should pass query parameters', async () => { + getAccountCredentials.mockReturnValue({ authId: 'MYAUTHID', authToken: 'MYAUTHTOKEN' }); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ objects: [] }) + }); + + await readService('test', 'Message/', { query: { limit: '10', offset: '0' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('limit=10'), + expect.any(Object) + ); + }); + + it('should handle API errors', async () => { + getAccountCredentials.mockReturnValue({ authId: 'MYAUTHID', authToken: 'MYAUTHTOKEN' }); + globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + await expect(readService('test', 'Message/')).rejects.toThrow('Network error'); + }); + + it('should fetch account info', async () => { + const mockAccount = { auth_id: 'MYAUTHID', name: 'Test Account' }; + getAccountCredentials.mockReturnValue({ authId: 'MYAUTHID', authToken: 'MYAUTHTOKEN' }); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAccount) + }); + + const result = await readService('test', ''); + expect(result.status).toBe(200); + expect(result.data.auth_id).toBe('MYAUTHID'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.plivo.com/v1/Account/MYAUTHID/', + expect.any(Object) + ); + }); + }); +});