This commit is contained in:
259
packages/agent/src/connectors/airtable-connector.ts
Normal file
259
packages/agent/src/connectors/airtable-connector.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Airtable Connector — access bases, records, and search.
|
||||
* Auth: Bearer (Personal Access Token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.airtable.com/v0';
|
||||
|
||||
export class AirtableConnector extends BaseConnector {
|
||||
readonly id = 'airtable';
|
||||
readonly name = 'Airtable';
|
||||
readonly description = "Read, create, and update Airtable records and views. Supports filtering, sorting, linked record traversal, and batch operations across bases and tables.";
|
||||
readonly service = 'airtable.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/airtable.svg';
|
||||
readonly category = 'data' as const;
|
||||
readonly setupGuide = "Create a Personal Access Token at airtable.com/create/tokens with data.records and schema scopes.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_bases',
|
||||
description: 'List all accessible bases',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
offset: { type: 'string', description: 'Pagination offset' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_records',
|
||||
description: 'List records from a table in a base',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
baseId: { type: 'string', description: 'Airtable base ID (e.g., "appXXXXXXXXXX")' },
|
||||
tableIdOrName: { type: 'string', description: 'Table ID or name' },
|
||||
maxRecords: { type: 'number', description: 'Max records to return (default 100)' },
|
||||
view: { type: 'string', description: 'View name or ID to filter by' },
|
||||
filterByFormula: { type: 'string', description: 'Airtable formula to filter records' },
|
||||
sort: { type: 'string', description: 'Sort field name' },
|
||||
sortDirection: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction' },
|
||||
},
|
||||
required: ['baseId', 'tableIdOrName'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_record',
|
||||
description: 'Get a single record by ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
baseId: { type: 'string', description: 'Airtable base ID' },
|
||||
tableIdOrName: { type: 'string', description: 'Table ID or name' },
|
||||
recordId: { type: 'string', description: 'Record ID (e.g., "recXXXXXXXXXX")' },
|
||||
},
|
||||
required: ['baseId', 'tableIdOrName', 'recordId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_record',
|
||||
description: 'Create a new record in a table',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
baseId: { type: 'string', description: 'Airtable base ID' },
|
||||
tableIdOrName: { type: 'string', description: 'Table ID or name' },
|
||||
fields: { type: 'object', description: 'Field name/value pairs for the new record' },
|
||||
},
|
||||
required: ['baseId', 'tableIdOrName', 'fields'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_record',
|
||||
description: 'Update an existing record',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
baseId: { type: 'string', description: 'Airtable base ID' },
|
||||
tableIdOrName: { type: 'string', description: 'Table ID or name' },
|
||||
recordId: { type: 'string', description: 'Record ID to update' },
|
||||
fields: { type: 'object', description: 'Field name/value pairs to update' },
|
||||
},
|
||||
required: ['baseId', 'tableIdOrName', 'recordId', 'fields'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_records',
|
||||
description: 'Search records using a formula filter',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
baseId: { type: 'string', description: 'Airtable base ID' },
|
||||
tableIdOrName: { type: 'string', description: 'Table ID or name' },
|
||||
filterByFormula: { type: 'string', description: 'Airtable formula (e.g., "FIND(\'search\', {Name})")' },
|
||||
maxRecords: { type: 'number', description: 'Max records to return (default 100)' },
|
||||
},
|
||||
required: ['baseId', 'tableIdOrName', 'filterByFormula'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
// List bases as health check (meta API)
|
||||
const res = await fetch('https://api.airtable.com/v0/meta/bases', {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Airtable API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Airtable access token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_bases': return this.listBases(params);
|
||||
case 'list_records': return this.listRecords(params);
|
||||
case 'get_record': return this.getRecord(params);
|
||||
case 'create_record': return this.createRecord(params);
|
||||
case 'update_record': return this.updateRecord(params);
|
||||
case 'search_records': return this.searchRecords(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listBases(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.offset) query.set('offset', String(params.offset));
|
||||
const qs = query.toString();
|
||||
const url = `https://api.airtable.com/v0/meta/bases${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Airtable API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listRecords(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const baseId = String(params.baseId);
|
||||
const tableIdOrName = String(params.tableIdOrName);
|
||||
const query = new URLSearchParams();
|
||||
if (params.maxRecords !== undefined) query.set('maxRecords', String(params.maxRecords));
|
||||
if (params.view) query.set('view', String(params.view));
|
||||
if (params.filterByFormula) query.set('filterByFormula', String(params.filterByFormula));
|
||||
if (params.sort) {
|
||||
query.set('sort[0][field]', String(params.sort));
|
||||
if (params.sortDirection) query.set('sort[0][direction]', String(params.sortDirection));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/${baseId}/${encodeURIComponent(tableIdOrName)}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Airtable API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const baseId = String(params.baseId);
|
||||
const tableIdOrName = String(params.tableIdOrName);
|
||||
const recordId = String(params.recordId);
|
||||
const url = `${API_BASE}/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Airtable API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const baseId = String(params.baseId);
|
||||
const tableIdOrName = String(params.tableIdOrName);
|
||||
const url = `${API_BASE}/${baseId}/${encodeURIComponent(tableIdOrName)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ fields: params.fields }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Airtable API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const baseId = String(params.baseId);
|
||||
const tableIdOrName = String(params.tableIdOrName);
|
||||
const recordId = String(params.recordId);
|
||||
const url = `${API_BASE}/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ fields: params.fields }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Airtable API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchRecords(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
// Airtable search is done via filterByFormula on list_records
|
||||
return this.listRecords({
|
||||
baseId: params.baseId,
|
||||
tableIdOrName: params.tableIdOrName,
|
||||
filterByFormula: params.filterByFormula,
|
||||
maxRecords: params.maxRecords ?? 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
254
packages/agent/src/connectors/asana-connector.ts
Normal file
254
packages/agent/src/connectors/asana-connector.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Asana Connector — manage tasks and projects via REST API.
|
||||
* Auth: Bearer (Personal Access Token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://app.asana.com/api/1.0';
|
||||
|
||||
export class AsanaConnector extends BaseConnector {
|
||||
readonly id = 'asana';
|
||||
readonly name = 'Asana';
|
||||
readonly description = "Manage Asana tasks, projects, and teams. Create and update tasks, manage assignees and due dates, search across workspaces, and track project milestones.";
|
||||
readonly service = 'asana.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/asana.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Create a Personal Access Token at app.asana.com/0/developer-console.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_tasks',
|
||||
description: 'List tasks in a project or assigned to a user',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
project: { type: 'string', description: 'Project GID to list tasks from' },
|
||||
assignee: { type: 'string', description: 'User GID or "me" for current user' },
|
||||
workspace: { type: 'string', description: 'Workspace GID (required with assignee)' },
|
||||
completed_since: { type: 'string', description: 'ISO date — only tasks completed after this date' },
|
||||
limit: { type: 'number', description: 'Results per page (max 100, default 50)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_task',
|
||||
description: 'Create a new task in Asana',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Task name' },
|
||||
notes: { type: 'string', description: 'Task description / notes' },
|
||||
projects: { type: 'array', items: { type: 'string' }, description: 'Project GIDs to add task to' },
|
||||
assignee: { type: 'string', description: 'Assignee user GID or "me"' },
|
||||
due_on: { type: 'string', description: 'Due date (YYYY-MM-DD)' },
|
||||
workspace: { type: 'string', description: 'Workspace GID (required if no project)' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Tag GIDs' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_task',
|
||||
description: 'Update an existing Asana task',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task GID to update' },
|
||||
name: { type: 'string', description: 'New task name' },
|
||||
notes: { type: 'string', description: 'New description' },
|
||||
completed: { type: 'boolean', description: 'Mark as completed (true/false)' },
|
||||
assignee: { type: 'string', description: 'New assignee user GID' },
|
||||
due_on: { type: 'string', description: 'New due date (YYYY-MM-DD)' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'List projects in a workspace',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace GID' },
|
||||
archived: { type: 'boolean', description: 'Include archived projects (default false)' },
|
||||
limit: { type: 'number', description: 'Results per page (max 100, default 50)' },
|
||||
},
|
||||
required: ['workspace'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_tasks',
|
||||
description: 'Search tasks in a workspace using text',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace GID to search in' },
|
||||
text: { type: 'string', description: 'Search query text' },
|
||||
completed: { type: 'boolean', description: 'Filter by completion (true/false)' },
|
||||
assignee: { type: 'string', description: 'Filter by assignee GID' },
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
},
|
||||
required: ['workspace', 'text'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Asana API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Asana access token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_tasks': return this.listTasks(params);
|
||||
case 'create_task': return this.createTask(params);
|
||||
case 'update_task': return this.updateTask(params);
|
||||
case 'list_projects': return this.listProjects(params);
|
||||
case 'search_tasks': return this.searchTasks(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Asana API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPost(path: string, body: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ data: body }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Asana API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPut(path: string, body: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ data: body }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Asana API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listTasks(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
if (params.project) queryParams.project = params.project;
|
||||
if (params.assignee) queryParams.assignee = params.assignee;
|
||||
if (params.workspace) queryParams.workspace = params.workspace;
|
||||
if (params.completed_since) queryParams.completed_since = params.completed_since;
|
||||
queryParams.limit = (params.limit as number) ?? 50;
|
||||
queryParams.opt_fields = 'name,completed,due_on,assignee.name,projects.name';
|
||||
return this.apiGet('/tasks', queryParams);
|
||||
}
|
||||
|
||||
private async createTask(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const body: Record<string, unknown> = { name: params.name };
|
||||
if (params.notes) body.notes = params.notes;
|
||||
if (params.projects) body.projects = params.projects;
|
||||
if (params.assignee) body.assignee = params.assignee;
|
||||
if (params.due_on) body.due_on = params.due_on;
|
||||
if (params.workspace) body.workspace = params.workspace;
|
||||
if (params.tags) body.tags = params.tags;
|
||||
return this.apiPost('/tasks', body);
|
||||
}
|
||||
|
||||
private async updateTask(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { taskId, ...updates } = params;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (updates.name) body.name = updates.name;
|
||||
if (updates.notes) body.notes = updates.notes;
|
||||
if (updates.completed !== undefined) body.completed = updates.completed;
|
||||
if (updates.assignee) body.assignee = updates.assignee;
|
||||
if (updates.due_on) body.due_on = updates.due_on;
|
||||
return this.apiPut(`/tasks/${encodeURIComponent(String(taskId))}`, body);
|
||||
}
|
||||
|
||||
private async listProjects(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {
|
||||
workspace: params.workspace,
|
||||
limit: (params.limit as number) ?? 50,
|
||||
opt_fields: 'name,archived,color,created_at,modified_at',
|
||||
};
|
||||
if (params.archived !== undefined) queryParams.archived = params.archived;
|
||||
return this.apiGet('/projects', queryParams, ['workspace']);
|
||||
}
|
||||
|
||||
private async searchTasks(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {
|
||||
text: params.text,
|
||||
};
|
||||
if (params.completed !== undefined) queryParams['completed'] = params.completed;
|
||||
if (params.assignee) queryParams['assignee.any'] = params.assignee;
|
||||
queryParams.limit = (params.limit as number) ?? 25;
|
||||
return this.apiGet(`/workspaces/${encodeURIComponent(String(params.workspace))}/tasks/search`, queryParams, ['workspace']);
|
||||
}
|
||||
}
|
||||
226
packages/agent/src/connectors/bitbucket-connector.ts
Normal file
226
packages/agent/src/connectors/bitbucket-connector.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Bitbucket Connector — access repositories, pull requests, issues, and files.
|
||||
* Auth: Bearer (App password or OAuth2 token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.bitbucket.org/2.0';
|
||||
|
||||
export class BitbucketConnector extends BaseConnector {
|
||||
readonly id = 'bitbucket';
|
||||
readonly name = 'Bitbucket';
|
||||
readonly description = "Access Bitbucket repositories, issues, and pull requests. Supports repo browsing, issue tracking, PR reviews, and code search across workspaces.";
|
||||
readonly service = 'bitbucket.org';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/bitbucket.svg';
|
||||
readonly category = 'development' as const;
|
||||
readonly setupGuide = "Create an App Password at bitbucket.org/account/settings/app-passwords with repository and issue permissions.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_repos',
|
||||
description: 'List your repositories',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace slug (defaults to authenticated user)' },
|
||||
sort: { type: 'string', description: 'Sort field (e.g., "-updated_on" for most recently updated)' },
|
||||
pagelen: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_pull_requests',
|
||||
description: 'List pull requests for a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace slug' },
|
||||
repo_slug: { type: 'string', description: 'Repository slug' },
|
||||
state: { type: 'string', enum: ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'], description: 'PR state filter' },
|
||||
pagelen: { type: 'number', description: 'Results per page (max 50)' },
|
||||
},
|
||||
required: ['workspace', 'repo_slug'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_file',
|
||||
description: 'Get file contents from a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace slug' },
|
||||
repo_slug: { type: 'string', description: 'Repository slug' },
|
||||
path: { type: 'string', description: 'File path in the repository' },
|
||||
commit: { type: 'string', description: 'Branch, tag, or commit hash (default: main)' },
|
||||
},
|
||||
required: ['workspace', 'repo_slug', 'path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_pull_request',
|
||||
description: 'Create a new pull request',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace slug' },
|
||||
repo_slug: { type: 'string', description: 'Repository slug' },
|
||||
title: { type: 'string', description: 'PR title' },
|
||||
description: { type: 'string', description: 'PR description (markdown)' },
|
||||
source_branch: { type: 'string', description: 'Source branch name' },
|
||||
destination_branch: { type: 'string', description: 'Destination branch (default: main)' },
|
||||
},
|
||||
required: ['workspace', 'repo_slug', 'title', 'source_branch'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_issues',
|
||||
description: 'List issues for a repository (requires issue tracker enabled)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
workspace: { type: 'string', description: 'Workspace slug' },
|
||||
repo_slug: { type: 'string', description: 'Repository slug' },
|
||||
state: { type: 'string', enum: ['new', 'open', 'resolved', 'on hold', 'invalid', 'duplicate', 'wontfix', 'closed'], description: 'Issue state filter' },
|
||||
pagelen: { type: 'number', description: 'Results per page (max 50)' },
|
||||
},
|
||||
required: ['workspace', 'repo_slug'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/user`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Bitbucket API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Bitbucket access token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_repos': return this.listRepos(params);
|
||||
case 'list_pull_requests': return this.apiGet(`/repositories/${params.workspace}/${params.repo_slug}/pullrequests`, params, ['workspace', 'repo_slug']);
|
||||
case 'get_file': return this.getFile(params);
|
||||
case 'create_pull_request': return this.createPR(params);
|
||||
case 'list_issues': return this.apiGet(`/repositories/${params.workspace}/${params.repo_slug}/issues`, params, ['workspace', 'repo_slug']);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listRepos(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const workspace = params.workspace ?? (await this.getUsername());
|
||||
if (!workspace) return { success: false, error: 'Could not determine workspace — provide workspace parameter' };
|
||||
return this.apiGet(`/repositories/${workspace}`, params, ['workspace']);
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getUsername(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/user`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const user = await res.json() as { username: string };
|
||||
return user.username;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const commit = params.commit ?? 'main';
|
||||
const url = `${API_BASE}/repositories/${params.workspace}/${params.repo_slug}/src/${encodeURIComponent(String(commit))}/${encodeURIComponent(String(params.path))}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Bitbucket API') };
|
||||
// Bitbucket returns raw file content, not JSON
|
||||
const content = await res.text();
|
||||
return { success: true, data: { content, path: params.path } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createPR(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body = {
|
||||
title: params.title,
|
||||
description: params.description ?? '',
|
||||
source: { branch: { name: params.source_branch } },
|
||||
destination: { branch: { name: params.destination_branch ?? 'main' } },
|
||||
};
|
||||
const url = `${API_BASE}/repositories/${params.workspace}/${params.repo_slug}/pullrequests`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Bitbucket API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Bitbucket API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
243
packages/agent/src/connectors/composio-connector.ts
Normal file
243
packages/agent/src/connectors/composio-connector.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Composio Connector — meta-connector bridging Waggle to Composio's 250+ integrations.
|
||||
* Auth: API Key (X-API-KEY header)
|
||||
*
|
||||
* Composio provides a single API to access 250+ services. This connector acts as
|
||||
* a bridge — it exposes Composio's action discovery and execution as Waggle tools.
|
||||
* All execute_action calls go through approval gates (risk level: high).
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://backend.composio.dev/api/v1';
|
||||
|
||||
export class ComposioConnector extends BaseConnector {
|
||||
readonly id = 'composio';
|
||||
readonly name = 'Composio (250+ services)';
|
||||
readonly description = "Meta-connector bridging to 250+ external services via Composio. Discover available integrations, list and execute actions across GitHub, Salesforce, HubSpot, Slack, and hundreds more through a single API key.";
|
||||
readonly service = 'composio.dev';
|
||||
readonly authType = 'api_key' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/databricks.svg';
|
||||
readonly category = 'integration' as const;
|
||||
readonly setupGuide = "Get your API key from app.composio.dev and connect your external services through the Composio dashboard.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_integrations',
|
||||
description: 'List all available integrations the user has connected in Composio',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page: { type: 'number', description: 'Page number (default: 1)' },
|
||||
pageSize: { type: 'number', description: 'Results per page (default: 20)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_actions',
|
||||
description: 'List available actions for a specific integration/app',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
appName: { type: 'string', description: 'The app/integration name (e.g., "github", "slack", "gmail")' },
|
||||
page: { type: 'number', description: 'Page number (default: 1)' },
|
||||
pageSize: { type: 'number', description: 'Results per page (default: 20)' },
|
||||
},
|
||||
required: ['appName'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'execute_action',
|
||||
description: 'Execute a specific Composio action with parameters (goes through approval gate)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
actionId: { type: 'string', description: 'The action ID to execute (from list_actions)' },
|
||||
params: { type: 'object', description: 'Parameters for the action' },
|
||||
connectedAccountId: { type: 'string', description: 'The connected account to use (from list_connected_accounts)' },
|
||||
},
|
||||
required: ['actionId'],
|
||||
},
|
||||
riskLevel: 'high',
|
||||
},
|
||||
{
|
||||
name: 'list_connected_accounts',
|
||||
description: 'List which external services the user has connected in Composio',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page: { type: 'number', description: 'Page number (default: 1)' },
|
||||
pageSize: { type: 'number', description: 'Results per page (default: 20)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_actions',
|
||||
description: 'Search across all available Composio actions by keyword',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
searchQuery: { type: 'string', description: 'Search query to find relevant actions' },
|
||||
page: { type: 'number', description: 'Page number (default: 1)' },
|
||||
pageSize: { type: 'number', description: 'Results per page (default: 20)' },
|
||||
},
|
||||
required: ['searchQuery'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private apiKey: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.apiKey = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.apiKey ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.apiKey) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/connectedAccounts`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Composio API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.apiKey) return { success: false, error: 'Not connected — add Composio API key in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_integrations': return this.listIntegrations(params);
|
||||
case 'list_actions': return this.listActions(params);
|
||||
case 'execute_action': return this.executeAction(params);
|
||||
case 'list_connected_accounts': return this.listConnectedAccounts(params);
|
||||
case 'search_actions': return this.searchActions(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
'X-API-KEY': this.apiKey!,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listIntegrations(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/integrations${qs ? `?${qs}` : ''}`;
|
||||
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Composio API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listActions(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.appName !== undefined) query.set('appName', String(params.appName));
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/actions${qs ? `?${qs}` : ''}`;
|
||||
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Composio API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const { actionId, params: actionParams, connectedAccountId } = params;
|
||||
if (!actionId) return { success: false, error: 'actionId is required' };
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (actionParams !== undefined) body.input = actionParams;
|
||||
if (connectedAccountId !== undefined) body.connectedAccountId = connectedAccountId;
|
||||
|
||||
const res = await fetch(`${API_BASE}/actions/${encodeURIComponent(String(actionId))}/execute`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Composio API') };
|
||||
|
||||
const data = await res.json();
|
||||
// Annotate result with action/service for transparency
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
actionId,
|
||||
service: 'composio',
|
||||
result: data,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listConnectedAccounts(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/connectedAccounts${qs ? `?${qs}` : ''}`;
|
||||
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Composio API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchActions(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.searchQuery !== undefined) query.set('searchQuery', String(params.searchQuery));
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/actions${qs ? `?${qs}` : ''}`;
|
||||
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Composio API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
273
packages/agent/src/connectors/confluence-connector.ts
Normal file
273
packages/agent/src/connectors/confluence-connector.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Confluence Connector — search, read, and manage Confluence pages and spaces.
|
||||
* Auth: Basic (email:apiToken) — Confluence Cloud uses email + API token, same as Jira.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
export class ConfluenceConnector extends BaseConnector {
|
||||
readonly id = 'confluence';
|
||||
readonly name = 'Confluence';
|
||||
readonly description = "Search and read Confluence pages and spaces. Retrieve documentation, meeting notes, and technical specs from your organization wiki.";
|
||||
readonly service = 'atlassian.net';
|
||||
readonly authType = 'basic' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/confluence.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Generate an API token at id.atlassian.com and use your Atlassian email and cloud URL.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'search_content',
|
||||
description: 'Search Confluence content using CQL (Confluence Query Language)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
cql: { type: 'string', description: 'CQL query (e.g., "type=page AND text~\\"project plan\\"")' },
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
},
|
||||
required: ['cql'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_page',
|
||||
description: 'Get a Confluence page by ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page_id: { type: 'string', description: 'Page ID' },
|
||||
body_format: { type: 'string', enum: ['storage', 'atlas_doc_format', 'view'], description: 'Body format (default: storage)' },
|
||||
},
|
||||
required: ['page_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_spaces',
|
||||
description: 'List all Confluence spaces',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
type: { type: 'string', enum: ['global', 'personal'], description: 'Filter by space type' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_page',
|
||||
description: 'Create a new Confluence page in a space',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
spaceId: { type: 'string', description: 'Space ID to create the page in' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
body: { type: 'string', description: 'Page body in storage format (XHTML)' },
|
||||
parentId: { type: 'string', description: 'Parent page ID (optional — creates as child page)' },
|
||||
status: { type: 'string', enum: ['current', 'draft'], description: 'Page status (default: current)' },
|
||||
},
|
||||
required: ['spaceId', 'title', 'body'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_page',
|
||||
description: 'Update an existing Confluence page',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page_id: { type: 'string', description: 'Page ID to update' },
|
||||
title: { type: 'string', description: 'New page title' },
|
||||
body: { type: 'string', description: 'New page body in storage format (XHTML)' },
|
||||
version_number: { type: 'number', description: 'Current version number (required for updates)' },
|
||||
status: { type: 'string', enum: ['current', 'draft'], description: 'Page status (default: current)' },
|
||||
},
|
||||
required: ['page_id', 'title', 'body', 'version_number'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private authHeader: string | null = null;
|
||||
private baseUrl: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
if (!cred) {
|
||||
this.authHeader = null;
|
||||
this.baseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const emailEntry = vault.get(`connector:${this.id}:email`);
|
||||
const email = emailEntry?.value ?? '';
|
||||
const apiToken = cred.value;
|
||||
|
||||
// Confluence Cloud uses email:apiToken as basic auth (same pattern as Jira)
|
||||
this.authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
|
||||
|
||||
// Domain from vault — constructs the wiki API v2 base URL
|
||||
const domainEntry = vault.get(`connector:${this.id}:domain`);
|
||||
const domain = domainEntry?.value ?? null;
|
||||
if (domain) {
|
||||
this.baseUrl = `https://${domain}.atlassian.net/wiki/api/v2`;
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.authHeader && this.baseUrl ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.authHeader && this.baseUrl) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/spaces?limit=1`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Confluence API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.authHeader || !this.baseUrl) {
|
||||
return { success: false, error: 'Not connected — add Confluence API token, email, and domain in vault' };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'search_content': return this.searchContent(params);
|
||||
case 'get_page': return this.getPage(params);
|
||||
case 'list_spaces': return this.listSpaces(params);
|
||||
case 'create_page': return this.createPage(params);
|
||||
case 'update_page': return this.updatePage(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: this.authHeader!,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async searchContent(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('cql', params.cql as string);
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/search?${query.toString()}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Confluence API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getPage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.body_format) query.set('body-format', params.body_format as string);
|
||||
|
||||
const qs = query.toString();
|
||||
const url = `${this.baseUrl}/pages/${params.page_id}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Confluence API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listSpaces(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.type) query.set('type', params.type as string);
|
||||
|
||||
const qs = query.toString();
|
||||
const url = `${this.baseUrl}/spaces${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Confluence API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createPage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
spaceId: params.spaceId,
|
||||
title: params.title,
|
||||
status: (params.status as string) ?? 'current',
|
||||
body: {
|
||||
representation: 'storage',
|
||||
value: params.body,
|
||||
},
|
||||
};
|
||||
if (params.parentId) body.parentId = params.parentId;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/pages`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Confluence API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
id: params.page_id,
|
||||
title: params.title,
|
||||
status: (params.status as string) ?? 'current',
|
||||
body: {
|
||||
representation: 'storage',
|
||||
value: params.body,
|
||||
},
|
||||
version: {
|
||||
number: params.version_number,
|
||||
message: 'Updated via Waggle',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/pages/${params.page_id}`, {
|
||||
method: 'PUT',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Confluence API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
192
packages/agent/src/connectors/discord-connector.ts
Normal file
192
packages/agent/src/connectors/discord-connector.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Discord Connector — list guilds, channels, read messages, search, and send messages.
|
||||
* Auth: Bot token (Authorization: Bot {token})
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://discord.com/api/v10';
|
||||
|
||||
export class DiscordConnector extends BaseConnector {
|
||||
readonly id = 'discord';
|
||||
readonly name = 'Discord';
|
||||
readonly description = "Read messages, search channels, and send notifications in Discord servers. Supports guild browsing, message search, and channel posting for bot integrations.";
|
||||
readonly service = 'discord.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/discord.svg';
|
||||
readonly category = 'communication' as const;
|
||||
readonly setupGuide = "Create a Discord Application at discord.com/developers, add a Bot, copy the Bot Token.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_guilds',
|
||||
description: 'List Discord guilds (servers) the bot has access to',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max guilds to return (default 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_channels',
|
||||
description: 'List channels in a Discord guild',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
guild_id: { type: 'string', description: 'Guild (server) ID' },
|
||||
},
|
||||
required: ['guild_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_messages',
|
||||
description: 'Get recent messages from a Discord channel',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
channel_id: { type: 'string', description: 'Channel ID' },
|
||||
limit: { type: 'number', description: 'Max messages to return (default 50)' },
|
||||
},
|
||||
required: ['channel_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'send_message',
|
||||
description: 'Send a message to a Discord channel',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
channel_id: { type: 'string', description: 'Channel ID' },
|
||||
content: { type: 'string', description: 'Message content (markdown supported)' },
|
||||
},
|
||||
required: ['channel_id', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_messages',
|
||||
description: 'Search messages in a Discord guild (may not be available to all bots, falls back to listing messages)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
guild_id: { type: 'string', description: 'Guild (server) ID' },
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
},
|
||||
required: ['guild_id', 'query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_guild_info',
|
||||
description: 'Get detailed information about a Discord guild',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
guild_id: { type: 'string', description: 'Guild (server) ID' },
|
||||
},
|
||||
required: ['guild_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/@me`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = await this.safeErrorText(res, 'Discord API error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Discord bot token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_guilds': return this.discordGet('/users/@me/guilds', params);
|
||||
case 'list_channels': return this.discordGet(`/guilds/${params.guild_id}/channels`, {});
|
||||
case 'get_messages': {
|
||||
const limit = params.limit ?? 50;
|
||||
return this.discordGet(`/channels/${params.channel_id}/messages`, { limit });
|
||||
}
|
||||
case 'send_message': return this.discordPost(`/channels/${params.channel_id}/messages`, { content: params.content });
|
||||
case 'search_messages': return this.discordGet(`/guilds/${params.guild_id}/messages/search`, { content: params.query });
|
||||
case 'get_guild_info': return this.discordGet(`/guilds/${params.guild_id}`, {});
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async discordGet(endpoint: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const res = await fetch(`${API_BASE}${endpoint}${qs ? `?${qs}` : ''}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await this.safeErrorText(res, 'Discord API error');
|
||||
return { success: false, error: errText };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { success: true, data };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async discordPost(endpoint: string, body: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await this.safeErrorText(res, 'Discord API error');
|
||||
return { success: false, error: errText };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { success: true, data };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
260
packages/agent/src/connectors/dropbox-connector.ts
Normal file
260
packages/agent/src/connectors/dropbox-connector.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Dropbox Connector — access files, folders, and search.
|
||||
* Auth: Bearer (OAuth2 access token)
|
||||
* Note: Dropbox uses POST for all endpoints. Content API for file transfer, RPC API for metadata.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const RPC_BASE = 'https://api.dropboxapi.com/2';
|
||||
const CONTENT_BASE = 'https://content.dropboxapi.com/2';
|
||||
|
||||
export class DropboxConnector extends BaseConnector {
|
||||
readonly id = 'dropbox';
|
||||
readonly name = 'Dropbox';
|
||||
readonly description = "Browse, read, and manage Dropbox files and folders. Supports directory listing, file content reading, upload, and search across personal and team accounts.";
|
||||
readonly service = 'dropbox.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/dropbox.svg';
|
||||
readonly category = 'storage' as const;
|
||||
readonly setupGuide = "Create an App at dropbox.com/developers and generate an Access Token.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_folder',
|
||||
description: 'List files and folders in a directory',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Folder path (e.g., "" for root, "/Documents")' },
|
||||
recursive: { type: 'boolean', description: 'Include subfolders (default false)' },
|
||||
limit: { type: 'number', description: 'Max results (default 100)' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_file_metadata',
|
||||
description: 'Get metadata for a file or folder',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'File or folder path' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_files',
|
||||
description: 'Search for files and folders by name or content',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
path: { type: 'string', description: 'Limit search to this folder path (optional)' },
|
||||
max_results: { type: 'number', description: 'Max results (default 100)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'download_file',
|
||||
description: 'Download file content (text files only, max 10MB)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'File path to download' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'upload_file',
|
||||
description: 'Upload a text file to Dropbox',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Destination path (e.g., "/Documents/notes.txt")' },
|
||||
content: { type: 'string', description: 'File content to upload (text only)' },
|
||||
mode: { type: 'string', enum: ['add', 'overwrite'], description: 'Write mode (default "add" — fails if exists)' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${RPC_BASE}/users/get_current_account`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: 'null',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Dropbox API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Dropbox access token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_folder': return this.listFolder(params);
|
||||
case 'get_file_metadata': return this.getMetadata(params);
|
||||
case 'search_files': return this.searchFiles(params);
|
||||
case 'download_file': return this.downloadFile(params);
|
||||
case 'upload_file': return this.uploadFile(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private rpcHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listFolder(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${RPC_BASE}/files/list_folder`, {
|
||||
method: 'POST',
|
||||
headers: this.rpcHeaders(),
|
||||
body: JSON.stringify({
|
||||
path: params.path === '' ? '' : params.path,
|
||||
recursive: params.recursive ?? false,
|
||||
limit: params.limit ?? 100,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Dropbox API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getMetadata(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${RPC_BASE}/files/get_metadata`, {
|
||||
method: 'POST',
|
||||
headers: this.rpcHeaders(),
|
||||
body: JSON.stringify({ path: params.path }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Dropbox API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchFiles(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
options: {
|
||||
max_results: params.max_results ?? 100,
|
||||
},
|
||||
};
|
||||
if (params.path) {
|
||||
(body.options as Record<string, unknown>).path_scope = params.path;
|
||||
}
|
||||
const res = await fetch(`${RPC_BASE}/files/search_v2`, {
|
||||
method: 'POST',
|
||||
headers: this.rpcHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Dropbox API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${CONTENT_BASE}/files/download`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Dropbox-API-Arg': JSON.stringify({ path: params.path }),
|
||||
},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Dropbox API') };
|
||||
|
||||
// Read as text (safe for text files; binary files should use a different approach)
|
||||
const content = await res.text();
|
||||
if (content.length > 10 * 1024 * 1024) {
|
||||
return { success: false, error: 'File too large (>10MB) — use Dropbox directly for large files' };
|
||||
}
|
||||
const metadata = res.headers.get('Dropbox-API-Result');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
content,
|
||||
metadata: metadata ? JSON.parse(metadata) : null,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const mode = params.mode === 'overwrite' ? { '.tag': 'overwrite' } : { '.tag': 'add' };
|
||||
const res = await fetch(`${CONTENT_BASE}/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Dropbox-API-Arg': JSON.stringify({
|
||||
path: params.path,
|
||||
mode,
|
||||
autorename: false,
|
||||
mute: false,
|
||||
}),
|
||||
},
|
||||
body: String(params.content),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Dropbox API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
224
packages/agent/src/connectors/email-connector.ts
Normal file
224
packages/agent/src/connectors/email-connector.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Email Connector — send emails via SendGrid.
|
||||
* Auth: API Key (SendGrid API key)
|
||||
* ALL send operations are high-risk (external communication) and require approval.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.sendgrid.com/v3';
|
||||
|
||||
export class EmailConnector extends BaseConnector {
|
||||
readonly id = 'email';
|
||||
readonly name = 'Email (SendGrid)';
|
||||
readonly description = "Send and receive email via SMTP/IMAP. Supports composing and sending messages, reading inbox, searching emails, and handling attachments across any email provider.";
|
||||
readonly service = 'sendgrid.com';
|
||||
readonly authType = 'api_key' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/maildotru.svg';
|
||||
readonly category = 'communication' as const;
|
||||
readonly setupGuide = "Provide SMTP host, port, username, and password. For Gmail use smtp.gmail.com with an App Password.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'send_email',
|
||||
description: 'Send a plain text or HTML email',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
subject: { type: 'string', description: 'Email subject' },
|
||||
body: { type: 'string', description: 'Email body (plain text or HTML)' },
|
||||
html: { type: 'boolean', description: 'If true, body is treated as HTML (default: false)' },
|
||||
cc: { type: 'string', description: 'CC email address (optional)' },
|
||||
bcc: { type: 'string', description: 'BCC email address (optional)' },
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
},
|
||||
riskLevel: 'high',
|
||||
},
|
||||
{
|
||||
name: 'send_template',
|
||||
description: 'Send an email using a SendGrid dynamic template',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
template_id: { type: 'string', description: 'SendGrid dynamic template ID' },
|
||||
variables: { type: 'object', description: 'Template variable key-value pairs' },
|
||||
},
|
||||
required: ['to', 'template_id'],
|
||||
},
|
||||
riskLevel: 'high',
|
||||
},
|
||||
{
|
||||
name: 'check_delivery',
|
||||
description: 'Check delivery status of a sent message',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
message_id: { type: 'string', description: 'SendGrid message ID' },
|
||||
},
|
||||
required: ['message_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private apiKey: string | null = null;
|
||||
private fromEmail = 'noreply@waggle.dev';
|
||||
private fromName = 'Waggle';
|
||||
private dailySendCount = 0;
|
||||
private dailyResetDate = new Date().toISOString().slice(0, 10);
|
||||
private maxDailyEmails = 100;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.apiKey = cred?.value ?? null;
|
||||
|
||||
// Optional from_email/from_name config
|
||||
const fromEmailEntry = vault.get(`connector:${this.id}:from_email`);
|
||||
if (fromEmailEntry) this.fromEmail = fromEmailEntry.value;
|
||||
const fromNameEntry = vault.get(`connector:${this.id}:from_name`);
|
||||
if (fromNameEntry) this.fromName = fromNameEntry.value;
|
||||
const maxEntry = vault.get(`connector:${this.id}:max_daily`);
|
||||
if (maxEntry) this.maxDailyEmails = parseInt(maxEntry.value, 10) || 100;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.apiKey ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.apiKey) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/user/profile`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `SendGrid API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.apiKey) return { success: false, error: 'Not connected — add SendGrid API key in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'send_email': return this.sendEmail(params);
|
||||
case 'send_template': return this.sendTemplate(params);
|
||||
case 'check_delivery': return this.checkDelivery(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private checkRateLimit(): string | null {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (today !== this.dailyResetDate) {
|
||||
this.dailySendCount = 0;
|
||||
this.dailyResetDate = today;
|
||||
}
|
||||
if (this.dailySendCount >= this.maxDailyEmails) {
|
||||
return `Daily email limit reached (${this.maxDailyEmails}/day). Resets at midnight UTC.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async sendEmail(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limitError = this.checkRateLimit();
|
||||
if (limitError) return { success: false, error: limitError };
|
||||
|
||||
try {
|
||||
const personalizations: Record<string, unknown>[] = [{ to: [{ email: params.to }] }];
|
||||
if (params.cc) personalizations[0].cc = [{ email: params.cc }];
|
||||
if (params.bcc) personalizations[0].bcc = [{ email: params.bcc }];
|
||||
|
||||
const content = params.html
|
||||
? [{ type: 'text/html', value: params.body }]
|
||||
: [{ type: 'text/plain', value: params.body }];
|
||||
|
||||
const res = await fetch(`${API_BASE}/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
personalizations,
|
||||
from: { email: this.fromEmail, name: this.fromName },
|
||||
subject: params.subject,
|
||||
content,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
// SendGrid returns 202 Accepted for successful sends
|
||||
if (res.status !== 202 && !res.ok) {
|
||||
return { success: false, error: await this.safeErrorText(res, 'SendGrid API') };
|
||||
}
|
||||
|
||||
this.dailySendCount++;
|
||||
const messageId = res.headers.get('X-Message-Id');
|
||||
return { success: true, data: { sent: true, to: params.to, messageId, dailySendCount: this.dailySendCount } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendTemplate(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limitError = this.checkRateLimit();
|
||||
if (limitError) return { success: false, error: limitError };
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
personalizations: [{
|
||||
to: [{ email: params.to }],
|
||||
dynamic_template_data: params.variables ?? {},
|
||||
}],
|
||||
from: { email: this.fromEmail, name: this.fromName },
|
||||
template_id: params.template_id,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (res.status !== 202 && !res.ok) {
|
||||
return { success: false, error: await this.safeErrorText(res, 'SendGrid API') };
|
||||
}
|
||||
|
||||
this.dailySendCount++;
|
||||
return { success: true, data: { sent: true, to: params.to, template: params.template_id } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDelivery(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/messages/${encodeURIComponent(String(params.message_id))}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'SendGrid API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
297
packages/agent/src/connectors/gcal-connector.ts
Normal file
297
packages/agent/src/connectors/gcal-connector.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Google Calendar Connector — manage events and find free time.
|
||||
* Auth: OAuth2 (access + refresh tokens in vault, auto-refresh on expiry)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const CALENDAR_API = 'https://www.googleapis.com/calendar/v3';
|
||||
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
export class GoogleCalendarConnector extends BaseConnector {
|
||||
readonly id = 'gcal';
|
||||
readonly name = 'Google Calendar';
|
||||
readonly description = "Read and create Google Calendar events, manage schedules, check availability, and handle meeting invites across multiple calendars.";
|
||||
readonly service = 'calendar.google.com';
|
||||
readonly authType = 'oauth2' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/googlecalendar.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
// Auto-fetch: list_events is read-only with no required params — safe to
|
||||
// harvest upcoming events into memory on a PRO schedule.
|
||||
readonly harvestAction = { action: 'list_events' };
|
||||
readonly setupGuide = "Enable Google Calendar API at console.cloud.google.com and create OAuth2 credentials.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_events',
|
||||
description: 'List upcoming calendar events',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
timeMin: { type: 'string', description: 'Start time (ISO 8601, default: now)' },
|
||||
timeMax: { type: 'string', description: 'End time (ISO 8601, default: 7 days from now)' },
|
||||
maxResults: { type: 'number', description: 'Max events to return (default 10)' },
|
||||
calendarId: { type: 'string', description: 'Calendar ID (default: primary)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_event',
|
||||
description: 'Create a new calendar event',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
summary: { type: 'string', description: 'Event title' },
|
||||
start: { type: 'string', description: 'Start time (ISO 8601)' },
|
||||
end: { type: 'string', description: 'End time (ISO 8601)' },
|
||||
description: { type: 'string', description: 'Event description' },
|
||||
attendees: { type: 'array', items: { type: 'string' }, description: 'Attendee email addresses' },
|
||||
calendarId: { type: 'string', description: 'Calendar ID (default: primary)' },
|
||||
},
|
||||
required: ['summary', 'start', 'end'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_event',
|
||||
description: 'Update an existing calendar event',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
eventId: { type: 'string', description: 'Event ID to update' },
|
||||
summary: { type: 'string', description: 'New event title' },
|
||||
start: { type: 'string', description: 'New start time (ISO 8601)' },
|
||||
end: { type: 'string', description: 'New end time (ISO 8601)' },
|
||||
description: { type: 'string', description: 'New description' },
|
||||
calendarId: { type: 'string', description: 'Calendar ID (default: primary)' },
|
||||
},
|
||||
required: ['eventId'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'find_free_time',
|
||||
description: 'Find available time slots across calendars',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
attendees: { type: 'array', items: { type: 'string' }, description: 'Email addresses to check availability for' },
|
||||
duration: { type: 'number', description: 'Desired slot duration in minutes' },
|
||||
timeMin: { type: 'string', description: 'Start of search range (ISO 8601)' },
|
||||
timeMax: { type: 'string', description: 'End of search range (ISO 8601)' },
|
||||
},
|
||||
required: ['duration', 'timeMin', 'timeMax'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private expiresAt: string | null = null;
|
||||
private clientId: string | null = null;
|
||||
private clientSecret: string | null = null;
|
||||
private vault: VaultStore | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
this.vault = vault;
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
if (cred) {
|
||||
this.accessToken = cred.value;
|
||||
this.refreshToken = cred.refreshToken ?? null;
|
||||
this.expiresAt = cred.expiresAt ?? null;
|
||||
}
|
||||
|
||||
const clientIdEntry = vault.get(`connector:${this.id}:client_id`);
|
||||
this.clientId = clientIdEntry?.value ?? null;
|
||||
const clientSecretEntry = vault.get(`connector:${this.id}:client_secret`);
|
||||
this.clientSecret = clientSecretEntry?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.accessToken ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
tokenExpiresAt: this.expiresAt ?? undefined,
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
try {
|
||||
await this.ensureValidToken();
|
||||
const res = await fetch(`${CALENDAR_API}/users/me/calendarList?maxResults=1`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Google Calendar API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.accessToken) return { success: false, error: 'Not connected — complete Google Calendar OAuth in Settings' };
|
||||
|
||||
try {
|
||||
await this.ensureValidToken();
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'list_events': return this.listEvents(params);
|
||||
case 'create_event': return this.createEvent(params);
|
||||
case 'update_event': return this.updateEvent(params);
|
||||
case 'find_free_time': return this.findFreeTime(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/** Refresh the access token if expired */
|
||||
private async ensureValidToken(): Promise<void> {
|
||||
if (!this.expiresAt) return; // No expiry info — assume valid
|
||||
if (new Date(this.expiresAt) > new Date()) return; // Still valid
|
||||
if (!this.refreshToken || !this.clientId || !this.clientSecret) {
|
||||
throw new Error('Cannot refresh token — missing refresh_token, client_id, or client_secret');
|
||||
}
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
refresh_token: this.refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
|
||||
|
||||
const data = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
|
||||
this.accessToken = data.access_token;
|
||||
this.expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
|
||||
if (data.refresh_token) this.refreshToken = data.refresh_token;
|
||||
|
||||
// Persist updated tokens back to vault
|
||||
if (this.vault) {
|
||||
this.vault.setConnectorCredential(this.id, {
|
||||
type: 'oauth2',
|
||||
value: this.accessToken,
|
||||
refreshToken: this.refreshToken ?? undefined,
|
||||
expiresAt: this.expiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async listEvents(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const calendarId = (params.calendarId as string) || 'primary';
|
||||
const timeMin = (params.timeMin as string) || new Date().toISOString();
|
||||
const timeMax = (params.timeMax as string) || new Date(Date.now() + 7 * 86400000).toISOString();
|
||||
const maxResults = (params.maxResults as number) || 10;
|
||||
|
||||
const query = new URLSearchParams({
|
||||
timeMin, timeMax, maxResults: String(maxResults),
|
||||
singleEvents: 'true', orderBy: 'startTime',
|
||||
});
|
||||
const res = await fetch(`${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createEvent(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const calendarId = (params.calendarId as string) || 'primary';
|
||||
const body: Record<string, unknown> = {
|
||||
summary: params.summary,
|
||||
start: { dateTime: params.start },
|
||||
end: { dateTime: params.end },
|
||||
};
|
||||
if (params.description) body.description = params.description;
|
||||
if (params.attendees) {
|
||||
body.attendees = (params.attendees as string[]).map(email => ({ email }));
|
||||
}
|
||||
|
||||
const res = await fetch(`${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateEvent(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const calendarId = (params.calendarId as string) || 'primary';
|
||||
const { eventId, calendarId: _, ...updates } = params;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (updates.summary) body.summary = updates.summary;
|
||||
if (updates.start) body.start = { dateTime: updates.start };
|
||||
if (updates.end) body.end = { dateTime: updates.end };
|
||||
if (updates.description) body.description = updates.description;
|
||||
|
||||
const res = await fetch(`${CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(String(eventId))}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async findFreeTime(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const attendees = (params.attendees as string[]) ?? [];
|
||||
const items = attendees.length > 0
|
||||
? attendees.map(email => ({ id: email }))
|
||||
: [{ id: 'primary' }];
|
||||
|
||||
const res = await fetch(`${CALENDAR_API}/freeBusy`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
timeMin: params.timeMin,
|
||||
timeMax: params.timeMax,
|
||||
items,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
195
packages/agent/src/connectors/gdocs-connector.ts
Normal file
195
packages/agent/src/connectors/gdocs-connector.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Google Docs Connector — create, read, and update Google Docs.
|
||||
* Auth: Bearer (OAuth2 access token in vault)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const DOCS_API = 'https://docs.googleapis.com/v1';
|
||||
const DRIVE_API = 'https://www.googleapis.com/drive/v3';
|
||||
|
||||
export class GoogleDocsConnector extends BaseConnector {
|
||||
readonly id = 'gdocs';
|
||||
readonly name = 'Google Docs';
|
||||
readonly description = "Read and edit Google Docs documents, manage comments, and extract structured content. Ideal for document workflows, review cycles, and content extraction pipelines.";
|
||||
readonly service = 'docs.google.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/googledocs.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Enable Google Docs API at console.cloud.google.com and create OAuth2 credentials.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'get_document',
|
||||
description: 'Get the full content of a Google Doc',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
documentId: { type: 'string', description: 'The Google Doc ID' },
|
||||
},
|
||||
required: ['documentId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_document',
|
||||
description: 'Create a new Google Doc',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Document title' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_document',
|
||||
description: 'Update a Google Doc using batchUpdate requests',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
documentId: { type: 'string', description: 'The Google Doc ID' },
|
||||
requests: { type: 'array', description: 'Array of batchUpdate request objects (insertText, deleteContentRange, etc.)' },
|
||||
},
|
||||
required: ['documentId', 'requests'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_comments',
|
||||
description: 'List comments on a Google Doc (via Drive API)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
documentId: { type: 'string', description: 'The Google Doc ID' },
|
||||
pageSize: { type: 'number', description: 'Max comments to return (default 20)' },
|
||||
pageToken: { type: 'string', description: 'Token for next page' },
|
||||
},
|
||||
required: ['documentId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
// Use Drive API about endpoint as a lightweight health check
|
||||
const res = await fetch(`${DRIVE_API}/about?fields=user`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Google Docs API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Google Docs token in Settings' };
|
||||
|
||||
switch (action) {
|
||||
case 'get_document': return this.getDocument(params);
|
||||
case 'create_document': return this.createDocument(params);
|
||||
case 'update_document': return this.updateDocument(params);
|
||||
case 'list_comments': return this.listComments(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async getDocument(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const documentId = params.documentId as string;
|
||||
const res = await fetch(`${DOCS_API}/documents/${encodeURIComponent(documentId)}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Docs API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createDocument(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const title = params.title as string;
|
||||
const res = await fetch(`${DOCS_API}/documents`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ title }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Docs API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDocument(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const documentId = params.documentId as string;
|
||||
const requests = params.requests as unknown[];
|
||||
const res = await fetch(`${DOCS_API}/documents/${encodeURIComponent(documentId)}:batchUpdate`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ requests }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Docs API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listComments(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const documentId = params.documentId as string;
|
||||
const pageSize = (params.pageSize as number) || 20;
|
||||
const query = new URLSearchParams({
|
||||
pageSize: String(pageSize),
|
||||
fields: 'comments(id,content,author,createdTime,resolved),nextPageToken',
|
||||
});
|
||||
if (params.pageToken) query.set('pageToken', String(params.pageToken));
|
||||
|
||||
const res = await fetch(`${DRIVE_API}/files/${encodeURIComponent(documentId)}/comments?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
295
packages/agent/src/connectors/gdrive-connector.ts
Normal file
295
packages/agent/src/connectors/gdrive-connector.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Google Drive Connector — list, search, download, and upload files.
|
||||
* Auth: Bearer (OAuth2 access token in vault)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://www.googleapis.com/drive/v3';
|
||||
const UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3';
|
||||
|
||||
export class GoogleDriveConnector extends BaseConnector {
|
||||
readonly id = 'gdrive';
|
||||
readonly name = 'Google Drive';
|
||||
readonly description = "Browse, read, upload, and manage Google Drive files and folders. Supports document listing, file search, content reading, and permission management.";
|
||||
readonly service = 'drive.google.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/googledrive.svg';
|
||||
readonly category = 'storage' as const;
|
||||
readonly setupGuide = "Enable Google Drive API at console.cloud.google.com and create OAuth2 credentials.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_files',
|
||||
description: 'List files in Google Drive',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
pageSize: { type: 'number', description: 'Max files to return (default 20)' },
|
||||
orderBy: { type: 'string', description: 'Sort order (e.g. "modifiedTime desc")' },
|
||||
pageToken: { type: 'string', description: 'Token for next page' },
|
||||
fields: { type: 'string', description: 'Fields to include (default: id,name,mimeType,modifiedTime,size)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_files',
|
||||
description: 'Search for files using Drive query syntax',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Drive search query (e.g. "name contains \'report\'" or "mimeType=\'application/pdf\'")' },
|
||||
pageSize: { type: 'number', description: 'Max results (default 20)' },
|
||||
pageToken: { type: 'string', description: 'Token for next page' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_file_metadata',
|
||||
description: 'Get metadata for a specific file',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
fileId: { type: 'string', description: 'The file ID' },
|
||||
fields: { type: 'string', description: 'Fields to include (default: id,name,mimeType,modifiedTime,size,parents,webViewLink)' },
|
||||
},
|
||||
required: ['fileId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'download_file',
|
||||
description: 'Download a file\'s content (returns text for text-based files)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
fileId: { type: 'string', description: 'The file ID' },
|
||||
},
|
||||
required: ['fileId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'upload_file',
|
||||
description: 'Upload a file to Google Drive',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
name: { type: 'string', description: 'File name' },
|
||||
content: { type: 'string', description: 'File content (text)' },
|
||||
mimeType: { type: 'string', description: 'MIME type (default: text/plain)' },
|
||||
parentId: { type: 'string', description: 'Parent folder ID (optional)' },
|
||||
},
|
||||
required: ['name', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'create_folder',
|
||||
description: 'Create a new folder in Google Drive',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Folder name' },
|
||||
parentId: { type: 'string', description: 'Parent folder ID (optional)' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/about?fields=user`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Google Drive API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Google Drive token in Settings' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_files': return this.listFiles(params);
|
||||
case 'search_files': return this.searchFiles(params);
|
||||
case 'get_file_metadata': return this.getFileMetadata(params);
|
||||
case 'download_file': return this.downloadFile(params);
|
||||
case 'upload_file': return this.uploadFile(params);
|
||||
case 'create_folder': return this.createFolder(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listFiles(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const pageSize = (params.pageSize as number) || 20;
|
||||
const fields = (params.fields as string) || 'files(id,name,mimeType,modifiedTime,size),nextPageToken';
|
||||
const query = new URLSearchParams({
|
||||
pageSize: String(pageSize),
|
||||
fields,
|
||||
});
|
||||
if (params.orderBy) query.set('orderBy', String(params.orderBy));
|
||||
if (params.pageToken) query.set('pageToken', String(params.pageToken));
|
||||
|
||||
const res = await fetch(`${API_BASE}/files?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchFiles(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const q = params.query as string;
|
||||
const pageSize = (params.pageSize as number) || 20;
|
||||
const query = new URLSearchParams({
|
||||
q,
|
||||
pageSize: String(pageSize),
|
||||
fields: 'files(id,name,mimeType,modifiedTime,size,parents,webViewLink),nextPageToken',
|
||||
});
|
||||
if (params.pageToken) query.set('pageToken', String(params.pageToken));
|
||||
|
||||
const res = await fetch(`${API_BASE}/files?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getFileMetadata(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const fileId = params.fileId as string;
|
||||
const fields = (params.fields as string) || 'id,name,mimeType,modifiedTime,size,parents,webViewLink';
|
||||
const query = new URLSearchParams({ fields });
|
||||
|
||||
const res = await fetch(`${API_BASE}/files/${encodeURIComponent(fileId)}?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const fileId = params.fileId as string;
|
||||
const res = await fetch(`${API_BASE}/files/${encodeURIComponent(fileId)}?alt=media`, {
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
|
||||
// Return text content (binary files would need different handling)
|
||||
const text = await res.text();
|
||||
return { success: true, data: { content: text, fileId } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const name = params.name as string;
|
||||
const content = params.content as string;
|
||||
const mimeType = (params.mimeType as string) || 'text/plain';
|
||||
const parentId = params.parentId as string | undefined;
|
||||
|
||||
// Multipart upload: metadata + content
|
||||
const metadata: Record<string, unknown> = { name, mimeType };
|
||||
if (parentId) metadata.parents = [parentId];
|
||||
|
||||
const boundary = 'waggle_upload_boundary';
|
||||
const body =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
|
||||
`${JSON.stringify(metadata)}\r\n` +
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: ${mimeType}\r\n\r\n` +
|
||||
`${content}\r\n` +
|
||||
`--${boundary}--`;
|
||||
|
||||
const res = await fetch(`${UPLOAD_API}/files?uploadType=multipart`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createFolder(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const name = params.name as string;
|
||||
const parentId = params.parentId as string | undefined;
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
name,
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
};
|
||||
if (parentId) metadata.parents = [parentId];
|
||||
|
||||
const res = await fetch(`${API_BASE}/files`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(metadata),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Drive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
221
packages/agent/src/connectors/github-connector.ts
Normal file
221
packages/agent/src/connectors/github-connector.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* GitHub Connector — access repositories, issues, and pull requests.
|
||||
* Auth: Bearer (Personal Access Token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
export class GitHubConnector extends BaseConnector {
|
||||
readonly id = 'github';
|
||||
readonly name = 'GitHub';
|
||||
readonly description = "Access GitHub repositories, issues, pull requests, and code search. Supports listing repos, searching code, managing issues, reading files, and creating commits.";
|
||||
readonly service = 'github.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/github.svg';
|
||||
readonly category = 'development' as const;
|
||||
// Auto-fetch: list_repos is read-only with no required params — safe to
|
||||
// harvest the user's repositories into memory on a PRO schedule.
|
||||
readonly harvestAction = { action: 'list_repos' };
|
||||
readonly setupGuide = "Create a Personal Access Token at github.com/settings/tokens with repo scope.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_repos',
|
||||
description: 'List your repositories',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
sort: { type: 'string', enum: ['created', 'updated', 'pushed', 'full_name'], description: 'Sort field' },
|
||||
per_page: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_code',
|
||||
description: 'Search code across GitHub repositories',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Search query (GitHub search syntax)' },
|
||||
per_page: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_issues',
|
||||
description: 'List issues for a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
owner: { type: 'string', description: 'Repository owner' },
|
||||
repo: { type: 'string', description: 'Repository name' },
|
||||
state: { type: 'string', enum: ['open', 'closed', 'all'] },
|
||||
per_page: { type: 'number' },
|
||||
},
|
||||
required: ['owner', 'repo'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_file',
|
||||
description: 'Get file contents from a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
owner: { type: 'string', description: 'Repository owner' },
|
||||
repo: { type: 'string', description: 'Repository name' },
|
||||
path: { type: 'string', description: 'File path in the repository' },
|
||||
ref: { type: 'string', description: 'Branch or commit SHA (default: main)' },
|
||||
},
|
||||
required: ['owner', 'repo', 'path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_issue',
|
||||
description: 'Create a new issue in a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
owner: { type: 'string', description: 'Repository owner' },
|
||||
repo: { type: 'string', description: 'Repository name' },
|
||||
title: { type: 'string', description: 'Issue title' },
|
||||
body: { type: 'string', description: 'Issue body (markdown)' },
|
||||
labels: { type: 'array', items: { type: 'string' }, description: 'Labels to add' },
|
||||
},
|
||||
required: ['owner', 'repo', 'title'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_prs',
|
||||
description: 'List pull requests for a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
owner: { type: 'string', description: 'Repository owner' },
|
||||
repo: { type: 'string', description: 'Repository name' },
|
||||
state: { type: 'string', enum: ['open', 'closed', 'all'] },
|
||||
per_page: { type: 'number' },
|
||||
},
|
||||
required: ['owner', 'repo'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_pr',
|
||||
description: 'Create a new pull request',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
owner: { type: 'string', description: 'Repository owner' },
|
||||
repo: { type: 'string', description: 'Repository name' },
|
||||
title: { type: 'string', description: 'PR title' },
|
||||
body: { type: 'string', description: 'PR description (markdown)' },
|
||||
head: { type: 'string', description: 'Branch containing changes' },
|
||||
base: { type: 'string', description: 'Branch to merge into (default: main)' },
|
||||
},
|
||||
required: ['owner', 'repo', 'title', 'head'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
private baseUrl = API_BASE;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
// Support GitHub Enterprise via connector config
|
||||
const configEntry = vault.get(`connector:${this.id}:base_url`);
|
||||
if (configEntry) this.baseUrl = configEntry.value;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/user`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `GitHub API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add GitHub token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_repos': return this.apiGet('/user/repos', params);
|
||||
case 'search_code': return this.apiGet('/search/code', params);
|
||||
case 'list_issues': return this.apiGet(`/repos/${params.owner}/${params.repo}/issues`, params, ['owner', 'repo']);
|
||||
case 'get_file': return this.apiGet(`/repos/${params.owner}/${params.repo}/contents/${params.path}`, params, ['owner', 'repo', 'path']);
|
||||
case 'create_issue': return this.apiPost(`/repos/${params.owner}/${params.repo}/issues`, params, ['owner', 'repo']);
|
||||
case 'list_prs': return this.apiGet(`/repos/${params.owner}/${params.repo}/pulls`, params, ['owner', 'repo']);
|
||||
case 'create_pr': return this.apiPost(`/repos/${params.owner}/${params.repo}/pulls`, params, ['owner', 'repo']);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'Waggle/1.0',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${this.baseUrl}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'GitHub API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPost(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) body[k] = v;
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.headers(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'GitHub API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
240
packages/agent/src/connectors/gitlab-connector.ts
Normal file
240
packages/agent/src/connectors/gitlab-connector.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* GitLab Connector — access projects, issues, merge requests, and code.
|
||||
* Auth: Bearer (Personal Access Token)
|
||||
* Supports self-hosted GitLab via vault metadata.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const DEFAULT_API_BASE = 'https://gitlab.com/api/v4';
|
||||
|
||||
export class GitLabConnector extends BaseConnector {
|
||||
readonly id = 'gitlab';
|
||||
readonly name = 'GitLab';
|
||||
readonly description = "Access GitLab repositories, issues, merge requests, and pipelines. Supports code browsing, issue management, MR reviews, and CI/CD pipeline inspection.";
|
||||
readonly service = 'gitlab.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/gitlab.svg';
|
||||
readonly category = 'development' as const;
|
||||
readonly setupGuide = "Create a Personal Access Token at gitlab.com/-/user_settings/personal_access_tokens with api scope.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'List your projects',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
membership: { type: 'boolean', description: 'Only projects you are a member of (default true)' },
|
||||
order_by: { type: 'string', enum: ['id', 'name', 'created_at', 'updated_at', 'last_activity_at'], description: 'Sort field' },
|
||||
per_page: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_issues',
|
||||
description: 'List issues for a project',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project ID or URL-encoded path (e.g., "user/repo")' },
|
||||
state: { type: 'string', enum: ['opened', 'closed', 'all'], description: 'Issue state filter' },
|
||||
labels: { type: 'string', description: 'Comma-separated label names' },
|
||||
per_page: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
required: ['project_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_issue',
|
||||
description: 'Create a new issue in a project',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project ID or URL-encoded path' },
|
||||
title: { type: 'string', description: 'Issue title' },
|
||||
description: { type: 'string', description: 'Issue description (markdown)' },
|
||||
labels: { type: 'string', description: 'Comma-separated label names' },
|
||||
assignee_ids: { type: 'array', items: { type: 'number' }, description: 'Assignee user IDs' },
|
||||
},
|
||||
required: ['project_id', 'title'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_merge_requests',
|
||||
description: 'List merge requests for a project',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project ID or URL-encoded path' },
|
||||
state: { type: 'string', enum: ['opened', 'closed', 'merged', 'all'], description: 'MR state filter' },
|
||||
per_page: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
required: ['project_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_file',
|
||||
description: 'Get file contents from a repository',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project ID or URL-encoded path' },
|
||||
file_path: { type: 'string', description: 'Path to the file in the repository' },
|
||||
ref: { type: 'string', description: 'Branch, tag, or commit (default: main)' },
|
||||
},
|
||||
required: ['project_id', 'file_path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_code',
|
||||
description: 'Search code across projects',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
search: { type: 'string', description: 'Search query' },
|
||||
project_id: { type: 'string', description: 'Limit search to a specific project (optional)' },
|
||||
per_page: { type: 'number', description: 'Results per page (max 100)' },
|
||||
},
|
||||
required: ['search'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
private baseUrl = DEFAULT_API_BASE;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
|
||||
// Support self-hosted GitLab via connector config
|
||||
const configEntry = vault.get(`connector:${this.id}:base_url`);
|
||||
if (configEntry) this.baseUrl = configEntry.value;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/user`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `GitLab API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add GitLab access token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_projects': return this.apiGet('/projects', { membership: true, ...params });
|
||||
case 'list_issues': return this.apiGet(`/projects/${this.encodeProject(params.project_id)}/issues`, params, ['project_id']);
|
||||
case 'create_issue': return this.apiPost(`/projects/${this.encodeProject(params.project_id)}/issues`, params, ['project_id']);
|
||||
case 'list_merge_requests': return this.apiGet(`/projects/${this.encodeProject(params.project_id)}/merge_requests`, params, ['project_id']);
|
||||
case 'get_file': return this.getFile(params);
|
||||
case 'search_code': return this.searchCode(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private encodeProject(projectId: unknown): string {
|
||||
return encodeURIComponent(String(projectId));
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
'PRIVATE-TOKEN': this.token!,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${this.baseUrl}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'GitLab API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPost(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) body[k] = v;
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'GitLab API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const projectId = this.encodeProject(params.project_id);
|
||||
const filePath = encodeURIComponent(String(params.file_path));
|
||||
const ref = params.ref ? `?ref=${encodeURIComponent(String(params.ref))}` : '';
|
||||
const url = `${this.baseUrl}/projects/${projectId}/repository/files/${filePath}${ref}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'GitLab API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchCode(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('scope', 'blobs');
|
||||
query.set('search', String(params.search));
|
||||
if (params.per_page !== undefined) query.set('per_page', String(params.per_page));
|
||||
|
||||
// Project-scoped or global search
|
||||
const basePath = params.project_id
|
||||
? `/projects/${this.encodeProject(params.project_id)}/search`
|
||||
: '/search';
|
||||
|
||||
const url = `${this.baseUrl}${basePath}?${query.toString()}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'GitLab API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
237
packages/agent/src/connectors/gmail-connector.ts
Normal file
237
packages/agent/src/connectors/gmail-connector.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Gmail Connector — read, search, and send emails via Gmail API.
|
||||
* Auth: Bearer (OAuth2 access token in vault)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://gmail.googleapis.com/gmail/v1';
|
||||
|
||||
export class GmailConnector extends BaseConnector {
|
||||
readonly id = 'gmail';
|
||||
readonly name = 'Gmail';
|
||||
readonly description = "Read, search, send, and organize Gmail messages and threads. Supports label management, attachment handling, and full-text search across your entire inbox.";
|
||||
readonly service = 'gmail.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/gmail.svg';
|
||||
readonly category = 'communication' as const;
|
||||
readonly setupGuide = "Enable Gmail API at console.cloud.google.com and create OAuth2 credentials for a Desktop application.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_messages',
|
||||
description: 'List recent email messages',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
maxResults: { type: 'number', description: 'Max messages to return (default 20)' },
|
||||
labelIds: { type: 'array', items: { type: 'string' }, description: 'Filter by label IDs (e.g. INBOX, UNREAD)' },
|
||||
pageToken: { type: 'string', description: 'Token for next page of results' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_message',
|
||||
description: 'Get a single email message with full content',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Message ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'send_message',
|
||||
description: 'Send an email message',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
subject: { type: 'string', description: 'Email subject' },
|
||||
body: { type: 'string', description: 'Email body (plain text)' },
|
||||
cc: { type: 'string', description: 'CC email address' },
|
||||
bcc: { type: 'string', description: 'BCC email address' },
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_messages',
|
||||
description: 'Search emails using Gmail search syntax',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Gmail search query (e.g. "from:user@example.com subject:report")' },
|
||||
maxResults: { type: 'number', description: 'Max results (default 20)' },
|
||||
pageToken: { type: 'string', description: 'Token for next page' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_labels',
|
||||
description: 'List all Gmail labels',
|
||||
inputSchema: {
|
||||
properties: {},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/profile`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Gmail API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Gmail token in Settings' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_messages': return this.listMessages(params);
|
||||
case 'get_message': return this.getMessage(params);
|
||||
case 'send_message': return this.sendMessage(params);
|
||||
case 'search_messages': return this.searchMessages(params);
|
||||
case 'list_labels': return this.listLabels();
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listMessages(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const maxResults = (params.maxResults as number) || 20;
|
||||
const query = new URLSearchParams({ maxResults: String(maxResults) });
|
||||
if (params.labelIds) {
|
||||
for (const label of params.labelIds as string[]) {
|
||||
query.append('labelIds', label);
|
||||
}
|
||||
}
|
||||
if (params.pageToken) query.set('pageToken', String(params.pageToken));
|
||||
|
||||
const res = await fetch(`${API_BASE}/users/me/messages?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Gmail API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getMessage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const id = params.id as string;
|
||||
const res = await fetch(`${API_BASE}/users/me/messages/${encodeURIComponent(id)}?format=full`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Gmail API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMessage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const to = params.to as string;
|
||||
const subject = params.subject as string;
|
||||
const body = params.body as string;
|
||||
const cc = params.cc as string | undefined;
|
||||
const bcc = params.bcc as string | undefined;
|
||||
|
||||
// Build RFC 2822 formatted email
|
||||
let rawEmail = `To: ${to}\r\n`;
|
||||
if (cc) rawEmail += `Cc: ${cc}\r\n`;
|
||||
if (bcc) rawEmail += `Bcc: ${bcc}\r\n`;
|
||||
rawEmail += `Subject: ${subject}\r\n`;
|
||||
rawEmail += `Content-Type: text/plain; charset="UTF-8"\r\n\r\n`;
|
||||
rawEmail += body;
|
||||
|
||||
// Base64url encode the email
|
||||
const encoded = Buffer.from(rawEmail).toString('base64url');
|
||||
|
||||
const res = await fetch(`${API_BASE}/users/me/messages/send`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ raw: encoded }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Gmail API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchMessages(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const q = params.query as string;
|
||||
const maxResults = (params.maxResults as number) || 20;
|
||||
const query = new URLSearchParams({ q, maxResults: String(maxResults) });
|
||||
if (params.pageToken) query.set('pageToken', String(params.pageToken));
|
||||
|
||||
const res = await fetch(`${API_BASE}/users/me/messages?${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Gmail API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listLabels(): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me/labels`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Gmail API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
249
packages/agent/src/connectors/gsheets-connector.ts
Normal file
249
packages/agent/src/connectors/gsheets-connector.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Google Sheets Connector — read, write, and manage spreadsheets.
|
||||
* Auth: Bearer (OAuth2 access token in vault)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://sheets.googleapis.com/v4';
|
||||
|
||||
export class GoogleSheetsConnector extends BaseConnector {
|
||||
readonly id = 'gsheets';
|
||||
readonly name = 'Google Sheets';
|
||||
readonly description = "Read, write, and analyze Google Sheets data. Supports cell updates, batch operations, sheet management, and formula-based data extraction at scale.";
|
||||
readonly service = 'sheets.google.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/googlesheets.svg';
|
||||
readonly category = 'data' as const;
|
||||
readonly setupGuide = "Enable Google Sheets API at console.cloud.google.com and create OAuth2 credentials.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'get_spreadsheet',
|
||||
description: 'Get spreadsheet metadata and sheet names',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
|
||||
},
|
||||
required: ['spreadsheetId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_values',
|
||||
description: 'Read cell values from a range',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
|
||||
range: { type: 'string', description: 'A1 notation range (e.g. "Sheet1!A1:D10")' },
|
||||
majorDimension: { type: 'string', enum: ['ROWS', 'COLUMNS'], description: 'Major dimension (default ROWS)' },
|
||||
},
|
||||
required: ['spreadsheetId', 'range'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'update_values',
|
||||
description: 'Write values to a cell range',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
|
||||
range: { type: 'string', description: 'A1 notation range (e.g. "Sheet1!A1:D10")' },
|
||||
values: { type: 'array', items: { type: 'array' }, description: 'Array of rows, each row is an array of cell values' },
|
||||
valueInputOption: { type: 'string', enum: ['RAW', 'USER_ENTERED'], description: 'How to interpret input (default USER_ENTERED)' },
|
||||
},
|
||||
required: ['spreadsheetId', 'range', 'values'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'append_values',
|
||||
description: 'Append rows to a sheet',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
|
||||
range: { type: 'string', description: 'A1 notation range to append after (e.g. "Sheet1!A:D")' },
|
||||
values: { type: 'array', items: { type: 'array' }, description: 'Array of rows to append' },
|
||||
valueInputOption: { type: 'string', enum: ['RAW', 'USER_ENTERED'], description: 'How to interpret input (default USER_ENTERED)' },
|
||||
},
|
||||
required: ['spreadsheetId', 'range', 'values'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'create_spreadsheet',
|
||||
description: 'Create a new spreadsheet',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Spreadsheet title' },
|
||||
sheetTitles: { type: 'array', items: { type: 'string' }, description: 'Sheet names to create (default: ["Sheet1"])' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
// Use Drive API about endpoint as a lightweight health check
|
||||
const res = await fetch('https://www.googleapis.com/drive/v3/about?fields=user', {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Google Sheets API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Google Sheets token in Settings' };
|
||||
|
||||
switch (action) {
|
||||
case 'get_spreadsheet': return this.getSpreadsheet(params);
|
||||
case 'get_values': return this.getValues(params);
|
||||
case 'update_values': return this.updateValues(params);
|
||||
case 'append_values': return this.appendValues(params);
|
||||
case 'create_spreadsheet': return this.createSpreadsheet(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async getSpreadsheet(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const spreadsheetId = params.spreadsheetId as string;
|
||||
const res = await fetch(`${API_BASE}/spreadsheets/${encodeURIComponent(spreadsheetId)}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Sheets API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getValues(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const spreadsheetId = params.spreadsheetId as string;
|
||||
const range = params.range as string;
|
||||
const query = new URLSearchParams();
|
||||
if (params.majorDimension) query.set('majorDimension', String(params.majorDimension));
|
||||
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Sheets API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateValues(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const spreadsheetId = params.spreadsheetId as string;
|
||||
const range = params.range as string;
|
||||
const values = params.values as unknown[][];
|
||||
const valueInputOption = (params.valueInputOption as string) || 'USER_ENTERED';
|
||||
|
||||
const query = new URLSearchParams({ valueInputOption });
|
||||
const url = `${API_BASE}/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}?${query}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ range, majorDimension: 'ROWS', values }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Sheets API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async appendValues(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const spreadsheetId = params.spreadsheetId as string;
|
||||
const range = params.range as string;
|
||||
const values = params.values as unknown[][];
|
||||
const valueInputOption = (params.valueInputOption as string) || 'USER_ENTERED';
|
||||
|
||||
const query = new URLSearchParams({ valueInputOption });
|
||||
const url = `${API_BASE}/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:append?${query}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ range, majorDimension: 'ROWS', values }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Sheets API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createSpreadsheet(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const title = params.title as string;
|
||||
const sheetTitles = (params.sheetTitles as string[]) || ['Sheet1'];
|
||||
|
||||
const body = {
|
||||
properties: { title },
|
||||
sheets: sheetTitles.map(sheetTitle => ({
|
||||
properties: { title: sheetTitle },
|
||||
})),
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/spreadsheets`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Google Sheets API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
243
packages/agent/src/connectors/hubspot-connector.ts
Normal file
243
packages/agent/src/connectors/hubspot-connector.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* HubSpot Connector — access contacts, deals, and companies.
|
||||
* Auth: Bearer (Private App access token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.hubapi.com';
|
||||
|
||||
export class HubSpotConnector extends BaseConnector {
|
||||
readonly id = 'hubspot';
|
||||
readonly name = 'HubSpot';
|
||||
readonly description = "Manage HubSpot contacts, companies, deals, and activities. Search CRM records, create and update properties, log activities, and track pipeline stages.";
|
||||
readonly service = 'hubspot.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/hubspot.svg';
|
||||
readonly category = 'crm' as const;
|
||||
readonly setupGuide = "Create a Private App at app.hubspot.com/private-apps with the required CRM scopes.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_contacts',
|
||||
description: 'List contacts with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 10, max 100)' },
|
||||
after: { type: 'string', description: 'Pagination cursor' },
|
||||
properties: { type: 'string', description: 'Comma-separated property names to include' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_contact',
|
||||
description: 'Get a single contact by ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
contactId: { type: 'string', description: 'HubSpot contact ID' },
|
||||
properties: { type: 'string', description: 'Comma-separated property names to include' },
|
||||
},
|
||||
required: ['contactId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_contact',
|
||||
description: 'Create a new contact',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
email: { type: 'string', description: 'Contact email address' },
|
||||
firstname: { type: 'string', description: 'First name' },
|
||||
lastname: { type: 'string', description: 'Last name' },
|
||||
phone: { type: 'string', description: 'Phone number' },
|
||||
company: { type: 'string', description: 'Company name' },
|
||||
},
|
||||
required: ['email'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_contacts',
|
||||
description: 'Search contacts by query',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query string' },
|
||||
limit: { type: 'number', description: 'Max results (default 10)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_deals',
|
||||
description: 'List deals with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 10, max 100)' },
|
||||
after: { type: 'string', description: 'Pagination cursor' },
|
||||
properties: { type: 'string', description: 'Comma-separated property names to include' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_deal',
|
||||
description: 'Create a new deal',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
dealname: { type: 'string', description: 'Deal name' },
|
||||
amount: { type: 'string', description: 'Deal amount' },
|
||||
dealstage: { type: 'string', description: 'Deal stage (e.g., "appointmentscheduled")' },
|
||||
pipeline: { type: 'string', description: 'Pipeline ID (default: "default")' },
|
||||
closedate: { type: 'string', description: 'Expected close date (ISO 8601)' },
|
||||
},
|
||||
required: ['dealname'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_companies',
|
||||
description: 'List companies with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 10, max 100)' },
|
||||
after: { type: 'string', description: 'Pagination cursor' },
|
||||
properties: { type: 'string', description: 'Comma-separated property names to include' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/crm/v3/objects/contacts?limit=1`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `HubSpot API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add HubSpot access token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_contacts': return this.listObjects('contacts', params);
|
||||
case 'get_contact': return this.getContact(params);
|
||||
case 'create_contact': return this.createObject('contacts', params);
|
||||
case 'search_contacts': return this.searchContacts(params);
|
||||
case 'list_deals': return this.listObjects('deals', params);
|
||||
case 'create_deal': return this.createObject('deals', params);
|
||||
case 'list_companies': return this.listObjects('companies', params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async listObjects(objectType: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.limit !== undefined) query.set('limit', String(params.limit));
|
||||
if (params.after) query.set('after', String(params.after));
|
||||
if (params.properties) {
|
||||
for (const prop of String(params.properties).split(',')) {
|
||||
query.append('properties', prop.trim());
|
||||
}
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/crm/v3/objects/${objectType}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'HubSpot API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getContact(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (params.properties) {
|
||||
for (const prop of String(params.properties).split(',')) {
|
||||
query.append('properties', prop.trim());
|
||||
}
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}/crm/v3/objects/contacts/${encodeURIComponent(String(params.contactId))}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'HubSpot API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createObject(objectType: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const properties: Record<string, unknown> = { ...params };
|
||||
const res = await fetch(`${API_BASE}/crm/v3/objects/${objectType}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ properties }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'HubSpot API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchContacts(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/crm/v3/objects/contacts/search`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
limit: params.limit ?? 10,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'HubSpot API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
30
packages/agent/src/connectors/index.ts
Normal file
30
packages/agent/src/connectors/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export { GitHubConnector } from './github-connector.js';
|
||||
export { SlackConnector } from './slack-connector.js';
|
||||
export { JiraConnector } from './jira-connector.js';
|
||||
export { EmailConnector } from './email-connector.js';
|
||||
export { GoogleCalendarConnector } from './gcal-connector.js';
|
||||
export { DiscordConnector } from './discord-connector.js';
|
||||
export { LinearConnector } from './linear-connector.js';
|
||||
export { AsanaConnector } from './asana-connector.js';
|
||||
export { TrelloConnector } from './trello-connector.js';
|
||||
export { MondayConnector } from './monday-connector.js';
|
||||
export { NotionConnector } from './notion-connector.js';
|
||||
export { ConfluenceConnector } from './confluence-connector.js';
|
||||
export { ObsidianConnector } from './obsidian-connector.js';
|
||||
export { HubSpotConnector } from './hubspot-connector.js';
|
||||
export { SalesforceConnector } from './salesforce-connector.js';
|
||||
export { PipedriveConnector } from './pipedrive-connector.js';
|
||||
export { AirtableConnector } from './airtable-connector.js';
|
||||
export { GitLabConnector } from './gitlab-connector.js';
|
||||
export { BitbucketConnector } from './bitbucket-connector.js';
|
||||
export { DropboxConnector } from './dropbox-connector.js';
|
||||
export { PostgresConnector } from './postgres-connector.js';
|
||||
export { GmailConnector } from './gmail-connector.js';
|
||||
export { GoogleDocsConnector } from './gdocs-connector.js';
|
||||
export { GoogleDriveConnector } from './gdrive-connector.js';
|
||||
export { GoogleSheetsConnector } from './gsheets-connector.js';
|
||||
export { ComposioConnector } from './composio-connector.js';
|
||||
export { MSTeamsConnector } from './ms-teams-connector.js';
|
||||
export { OutlookConnector } from './outlook-connector.js';
|
||||
export { OneDriveConnector } from './onedrive-connector.js';
|
||||
export { OneNoteConnector } from './onenote-connector.js';
|
||||
256
packages/agent/src/connectors/jira-connector.ts
Normal file
256
packages/agent/src/connectors/jira-connector.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Jira Connector — manage issues, search, and transition workflows.
|
||||
* Auth: Basic (email:apiToken) — Jira Cloud uses email + API token.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
export class JiraConnector extends BaseConnector {
|
||||
readonly id = 'jira';
|
||||
readonly name = 'Jira';
|
||||
readonly description = "Manage Jira issues, projects, and sprints. Search issues with JQL, create and update tickets, transition statuses, and add comments across all Jira projects.";
|
||||
readonly service = 'atlassian.net';
|
||||
readonly authType = 'bearer' as const; // Presents as bearer in UI, uses basic internally
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/jira.svg';
|
||||
readonly category = 'development' as const;
|
||||
readonly setupGuide = "Generate an API token at id.atlassian.com/manage-profile/security/api-tokens and use your Atlassian email as username.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_issues',
|
||||
description: 'List issues with optional JQL filter',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
jql: { type: 'string', description: 'JQL query (default: all open issues)' },
|
||||
maxResults: { type: 'number', description: 'Max results (default 50)' },
|
||||
fields: { type: 'string', description: 'Comma-separated field names to return' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search issues using JQL',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
jql: { type: 'string', description: 'JQL query (e.g., "project = PROJ AND status = Open")' },
|
||||
maxResults: { type: 'number', description: 'Max results (default 50)' },
|
||||
},
|
||||
required: ['jql'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_issue',
|
||||
description: 'Create a new Jira issue',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
project: { type: 'string', description: 'Project key (e.g., "PROJ")' },
|
||||
summary: { type: 'string', description: 'Issue summary/title' },
|
||||
description: { type: 'string', description: 'Issue description' },
|
||||
issuetype: { type: 'string', description: 'Issue type (e.g., "Bug", "Task", "Story")' },
|
||||
priority: { type: 'string', description: 'Priority name (e.g., "High", "Medium")' },
|
||||
labels: { type: 'array', items: { type: 'string' }, description: 'Labels to add' },
|
||||
},
|
||||
required: ['project', 'summary', 'issuetype'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_issue',
|
||||
description: 'Update an existing Jira issue',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
issueKey: { type: 'string', description: 'Issue key (e.g., "PROJ-123")' },
|
||||
summary: { type: 'string', description: 'New summary' },
|
||||
description: { type: 'string', description: 'New description' },
|
||||
priority: { type: 'string', description: 'New priority' },
|
||||
labels: { type: 'array', items: { type: 'string' }, description: 'New labels' },
|
||||
},
|
||||
required: ['issueKey'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'transition_issue',
|
||||
description: 'Transition an issue to a new status (e.g., In Progress, Done)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
issueKey: { type: 'string', description: 'Issue key (e.g., "PROJ-123")' },
|
||||
transitionName: { type: 'string', description: 'Transition name (e.g., "Start Progress", "Done")' },
|
||||
},
|
||||
required: ['issueKey', 'transitionName'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private authHeader: string | null = null;
|
||||
private baseUrl: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
if (!cred) {
|
||||
this.authHeader = null;
|
||||
this.baseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const emailEntry = vault.get(`connector:${this.id}:email`);
|
||||
const email = emailEntry?.value ?? '';
|
||||
const apiToken = cred.value;
|
||||
|
||||
// Jira Cloud uses email:apiToken as basic auth
|
||||
this.authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
|
||||
|
||||
// Base URL from vault or default
|
||||
const urlEntry = vault.get(`connector:${this.id}:base_url`);
|
||||
this.baseUrl = urlEntry?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.authHeader && this.baseUrl ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.authHeader && this.baseUrl) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/rest/api/3/myself`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Jira API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.authHeader || !this.baseUrl) {
|
||||
return { success: false, error: 'Not connected — add Jira API token and instance URL in vault' };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'list_issues': return this.search(params);
|
||||
case 'search': return this.search(params);
|
||||
case 'create_issue': return this.createIssue(params);
|
||||
case 'update_issue': return this.updateIssue(params);
|
||||
case 'transition_issue': return this.transitionIssue(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: this.authHeader!,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async search(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const jql = (params.jql as string) ?? 'order by created DESC';
|
||||
const maxResults = (params.maxResults as number) ?? 50;
|
||||
const fields = (params.fields as string) ?? 'summary,status,priority,assignee,created';
|
||||
const res = await fetch(`${this.baseUrl}/rest/api/3/search`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ jql, maxResults, fields: fields.split(',').map(f => f.trim()) }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Jira API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const fields: Record<string, unknown> = {
|
||||
project: { key: params.project },
|
||||
summary: params.summary,
|
||||
issuetype: { name: params.issuetype },
|
||||
};
|
||||
if (params.description) fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: params.description }] }] };
|
||||
if (params.priority) fields.priority = { name: params.priority };
|
||||
if (params.labels) fields.labels = params.labels;
|
||||
|
||||
const res = await fetch(`${this.baseUrl!}/rest/api/3/issue`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ fields }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Jira API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const { issueKey, ...updates } = params;
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (updates.summary) fields.summary = updates.summary;
|
||||
if (updates.description) fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: updates.description }] }] };
|
||||
if (updates.priority) fields.priority = { name: updates.priority };
|
||||
if (updates.labels) fields.labels = updates.labels;
|
||||
|
||||
const res = await fetch(`${this.baseUrl!}/rest/api/3/issue/${encodeURIComponent(String(issueKey))}`, {
|
||||
method: 'PUT',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ fields }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Jira API') };
|
||||
return { success: true, data: { key: issueKey, updated: true } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async transitionIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const { issueKey, transitionName } = params;
|
||||
// First, get available transitions
|
||||
const transRes = await fetch(`${this.baseUrl!}/rest/api/3/issue/${encodeURIComponent(String(issueKey))}/transitions`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!transRes.ok) return { success: false, error: await this.safeErrorText(transRes, 'Jira API') };
|
||||
const { transitions } = await transRes.json() as { transitions: Array<{ id: string; name: string }> };
|
||||
|
||||
const match = transitions.find(t => t.name.toLowerCase() === String(transitionName).toLowerCase());
|
||||
if (!match) {
|
||||
return { success: false, error: `Transition "${transitionName}" not available. Available: ${transitions.map(t => t.name).join(', ')}` };
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.baseUrl!}/rest/api/3/issue/${encodeURIComponent(String(issueKey))}/transitions`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ transition: { id: match.id } }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Jira API') };
|
||||
return { success: true, data: { key: issueKey, transitioned: transitionName } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
237
packages/agent/src/connectors/linear-connector.ts
Normal file
237
packages/agent/src/connectors/linear-connector.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Linear Connector — manage issues, projects, and teams via GraphQL API.
|
||||
* Auth: Bearer (API key)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_URL = 'https://api.linear.app/graphql';
|
||||
|
||||
export class LinearConnector extends BaseConnector {
|
||||
readonly id = 'linear';
|
||||
readonly name = 'Linear';
|
||||
readonly description = "Manage Linear issues, projects, cycles, and teams. Create issues, update statuses, assign work, search across projects, and track engineering velocity.";
|
||||
readonly service = 'linear.app';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/linear.svg';
|
||||
readonly category = 'development' as const;
|
||||
readonly setupGuide = "Create a Personal API Key at linear.app/settings/api.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_issues',
|
||||
description: 'List issues with optional filters',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
teamId: { type: 'string', description: 'Filter by team ID' },
|
||||
first: { type: 'number', description: 'Number of issues to return (default 50)' },
|
||||
state: { type: 'string', description: 'Filter by state name (e.g., "In Progress", "Done")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_issue',
|
||||
description: 'Create a new issue in Linear',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Issue title' },
|
||||
description: { type: 'string', description: 'Issue description (markdown)' },
|
||||
teamId: { type: 'string', description: 'Team ID to create issue in' },
|
||||
priority: { type: 'number', description: 'Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)' },
|
||||
assigneeId: { type: 'string', description: 'User ID to assign to' },
|
||||
labelIds: { type: 'array', items: { type: 'string' }, description: 'Label IDs to add' },
|
||||
},
|
||||
required: ['title', 'teamId'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_issue',
|
||||
description: 'Update an existing Linear issue',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
issueId: { type: 'string', description: 'Issue ID to update' },
|
||||
title: { type: 'string', description: 'New title' },
|
||||
description: { type: 'string', description: 'New description' },
|
||||
priority: { type: 'number', description: 'New priority (0-4)' },
|
||||
stateId: { type: 'string', description: 'New state ID' },
|
||||
assigneeId: { type: 'string', description: 'New assignee user ID' },
|
||||
},
|
||||
required: ['issueId'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_issues',
|
||||
description: 'Search issues by text query',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query text' },
|
||||
first: { type: 'number', description: 'Number of results (default 25)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'List projects in the workspace',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
first: { type: 'number', description: 'Number of projects to return (default 50)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_teams',
|
||||
description: 'List teams in the workspace',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
first: { type: 'number', description: 'Number of teams to return (default 50)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ viewer { id name } }' }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Linear API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Linear API key in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_issues': return this.listIssues(params);
|
||||
case 'create_issue': return this.createIssue(params);
|
||||
case 'update_issue': return this.updateIssue(params);
|
||||
case 'search_issues': return this.searchIssues(params);
|
||||
case 'list_projects': return this.listProjects(params);
|
||||
case 'list_teams': return this.listTeams(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: this.token!,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async graphql(query: string, variables?: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = { query };
|
||||
if (variables) body.variables = variables;
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Linear API') };
|
||||
const json = await res.json() as { data?: unknown; errors?: Array<{ message: string }> };
|
||||
if (json.errors?.length) {
|
||||
return { success: false, error: `Linear GraphQL: ${json.errors.map(e => e.message).join('; ')}` };
|
||||
}
|
||||
return { success: true, data: json.data };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listIssues(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 50;
|
||||
const filter: string[] = [];
|
||||
if (params.teamId) filter.push(`team: { id: { eq: "${params.teamId}" } }`);
|
||||
if (params.state) filter.push(`state: { name: { eq: "${params.state}" } }`);
|
||||
const filterClause = filter.length ? `(filter: { ${filter.join(', ')} }, first: ${first})` : `(first: ${first})`;
|
||||
return this.graphql(`{ issues${filterClause} { nodes { id identifier title state { name } priority assignee { name } createdAt } } }`);
|
||||
}
|
||||
|
||||
private async createIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const input: Record<string, unknown> = {
|
||||
title: params.title,
|
||||
teamId: params.teamId,
|
||||
};
|
||||
if (params.description) input.description = params.description;
|
||||
if (params.priority !== undefined) input.priority = params.priority;
|
||||
if (params.assigneeId) input.assigneeId = params.assigneeId;
|
||||
if (params.labelIds) input.labelIds = params.labelIds;
|
||||
|
||||
return this.graphql(
|
||||
`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } }`,
|
||||
{ input },
|
||||
);
|
||||
}
|
||||
|
||||
private async updateIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { issueId, ...updates } = params;
|
||||
const input: Record<string, unknown> = {};
|
||||
if (updates.title) input.title = updates.title;
|
||||
if (updates.description) input.description = updates.description;
|
||||
if (updates.priority !== undefined) input.priority = updates.priority;
|
||||
if (updates.stateId) input.stateId = updates.stateId;
|
||||
if (updates.assigneeId) input.assigneeId = updates.assigneeId;
|
||||
|
||||
return this.graphql(
|
||||
`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id identifier title state { name } } } }`,
|
||||
{ id: issueId, input },
|
||||
);
|
||||
}
|
||||
|
||||
private async searchIssues(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 25;
|
||||
return this.graphql(
|
||||
`query($query: String!, $first: Int) { searchIssues(query: $query, first: $first) { nodes { id identifier title state { name } priority assignee { name } } } }`,
|
||||
{ query: params.query, first },
|
||||
);
|
||||
}
|
||||
|
||||
private async listProjects(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 50;
|
||||
return this.graphql(`{ projects(first: ${first}) { nodes { id name state startDate targetDate } } }`);
|
||||
}
|
||||
|
||||
private async listTeams(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 50;
|
||||
return this.graphql(`{ teams(first: ${first}) { nodes { id name key description } } }`);
|
||||
}
|
||||
}
|
||||
210
packages/agent/src/connectors/monday-connector.ts
Normal file
210
packages/agent/src/connectors/monday-connector.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Monday.com Connector — manage boards and items via GraphQL API.
|
||||
* Auth: Bearer (API v2 token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_URL = 'https://api.monday.com/v2';
|
||||
|
||||
export class MondayConnector extends BaseConnector {
|
||||
readonly id = 'monday';
|
||||
readonly name = 'Monday.com';
|
||||
readonly description = "Read and update Monday.com boards, items, and columns. Query work items, update statuses, manage assignments, and track project progress across boards.";
|
||||
readonly service = 'monday.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/mondaydotcom.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Get your API Token from monday.com Profile > Developers > API.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_boards',
|
||||
description: 'List boards accessible to the user',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Number of boards to return (default 25)' },
|
||||
page: { type: 'number', description: 'Page number (default 1)' },
|
||||
board_kind: { type: 'string', enum: ['public', 'private', 'share'], description: 'Filter by board kind' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_items',
|
||||
description: 'List items (rows) in a board',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
boardId: { type: 'string', description: 'Board ID to list items from' },
|
||||
limit: { type: 'number', description: 'Number of items to return (default 50)' },
|
||||
groupId: { type: 'string', description: 'Filter by group ID within the board' },
|
||||
},
|
||||
required: ['boardId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_item',
|
||||
description: 'Create a new item (row) in a board',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
boardId: { type: 'string', description: 'Board ID to create item in' },
|
||||
itemName: { type: 'string', description: 'Item name' },
|
||||
groupId: { type: 'string', description: 'Group ID to place item in (optional)' },
|
||||
columnValues: { type: 'string', description: 'JSON string of column values (e.g., \'{"status": {"label": "Working on it"}}\')' },
|
||||
},
|
||||
required: ['boardId', 'itemName'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_item',
|
||||
description: 'Update column values of an existing item',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
boardId: { type: 'string', description: 'Board ID containing the item' },
|
||||
itemId: { type: 'string', description: 'Item ID to update' },
|
||||
columnValues: { type: 'string', description: 'JSON string of column values to update' },
|
||||
},
|
||||
required: ['boardId', 'itemId', 'columnValues'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_items',
|
||||
description: 'Search items across boards by text',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query text' },
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ me { id name } }' }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Monday.com API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Monday.com API token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_boards': return this.listBoards(params);
|
||||
case 'list_items': return this.listItems(params);
|
||||
case 'create_item': return this.createItem(params);
|
||||
case 'update_item': return this.updateItem(params);
|
||||
case 'search_items': return this.searchItems(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: this.token!,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async graphql(query: string, variables?: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = { query };
|
||||
if (variables) body.variables = variables;
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Monday.com API') };
|
||||
const json = await res.json() as { data?: unknown; errors?: Array<{ message: string }> };
|
||||
if (json.errors?.length) {
|
||||
return { success: false, error: `Monday.com GraphQL: ${json.errors.map(e => e.message).join('; ')}` };
|
||||
}
|
||||
return { success: true, data: json.data };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listBoards(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limit = (params.limit as number) ?? 25;
|
||||
const page = (params.page as number) ?? 1;
|
||||
const kindFilter = params.board_kind ? `, board_kind: ${params.board_kind}` : '';
|
||||
return this.graphql(`{ boards(limit: ${limit}, page: ${page}${kindFilter}) { id name state board_kind columns { id title type } groups { id title } } }`);
|
||||
}
|
||||
|
||||
private async listItems(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limit = (params.limit as number) ?? 50;
|
||||
const boardId = params.boardId;
|
||||
if (params.groupId) {
|
||||
return this.graphql(
|
||||
`{ boards(ids: [${boardId}]) { groups(ids: ["${params.groupId}"]) { items_page(limit: ${limit}) { items { id name column_values { id text value } } } } } }`,
|
||||
);
|
||||
}
|
||||
return this.graphql(
|
||||
`{ boards(ids: [${boardId}]) { items_page(limit: ${limit}) { items { id name group { id title } column_values { id text value } } } } }`,
|
||||
);
|
||||
}
|
||||
|
||||
private async createItem(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { boardId, itemName, groupId, columnValues } = params;
|
||||
let mutation = `mutation { create_item(board_id: ${boardId}, item_name: "${String(itemName).replace(/"/g, '\\"')}"`;
|
||||
if (groupId) mutation += `, group_id: "${groupId}"`;
|
||||
if (columnValues) mutation += `, column_values: ${JSON.stringify(String(columnValues))}`;
|
||||
mutation += `) { id name } }`;
|
||||
return this.graphql(mutation);
|
||||
}
|
||||
|
||||
private async updateItem(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { boardId, itemId, columnValues } = params;
|
||||
return this.graphql(
|
||||
`mutation { change_multiple_column_values(board_id: ${boardId}, item_id: ${itemId}, column_values: ${JSON.stringify(String(columnValues))}) { id name } }`,
|
||||
);
|
||||
}
|
||||
|
||||
private async searchItems(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limit = (params.limit as number) ?? 25;
|
||||
const query = String(params.query).replace(/"/g, '\\"');
|
||||
return this.graphql(
|
||||
`{ items_page_by_column_values(limit: ${limit}, board_id: 0, columns: [{column_id: "name", column_values: ["${query}"]}]) { items { id name board { id name } column_values { id text value } } } }`,
|
||||
);
|
||||
}
|
||||
}
|
||||
199
packages/agent/src/connectors/ms-teams-connector.ts
Normal file
199
packages/agent/src/connectors/ms-teams-connector.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Microsoft Teams Connector — list teams, channels, messages, and chats via Microsoft Graph API.
|
||||
* Auth: Bearer (Microsoft Graph API access token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
export class MSTeamsConnector extends BaseConnector {
|
||||
readonly id = 'ms-teams';
|
||||
readonly name = 'Microsoft Teams';
|
||||
readonly description = "Read and send Microsoft Teams messages across channels and chats. Supports team browsing, message history, and posting to any accessible channel.";
|
||||
readonly service = 'teams.microsoft.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/microsoftteams.svg';
|
||||
readonly category = 'communication' as const;
|
||||
readonly setupGuide = "Register an app in Azure AD with Teams permissions and use client credentials flow.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_teams',
|
||||
description: 'List teams the user has joined',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
$top: { type: 'number', description: 'Max teams to return (default 50)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_channels',
|
||||
description: 'List channels in a team',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Team ID' },
|
||||
},
|
||||
required: ['team_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_messages',
|
||||
description: 'Get messages from a team channel',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Team ID' },
|
||||
channel_id: { type: 'string', description: 'Channel ID' },
|
||||
$top: { type: 'number', description: 'Max messages to return (default 20)' },
|
||||
},
|
||||
required: ['team_id', 'channel_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'send_message',
|
||||
description: 'Send a message to a team channel',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Team ID' },
|
||||
channel_id: { type: 'string', description: 'Channel ID' },
|
||||
content: { type: 'string', description: 'Message content (HTML supported)' },
|
||||
},
|
||||
required: ['team_id', 'channel_id', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_chats',
|
||||
description: 'List 1:1 and group chats for the current user',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
$top: { type: 'number', description: 'Max chats to return (default 50)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'send_chat_message',
|
||||
description: 'Send a message in a 1:1 or group chat',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
chat_id: { type: 'string', description: 'Chat ID' },
|
||||
content: { type: 'string', description: 'Message content (HTML supported)' },
|
||||
},
|
||||
required: ['chat_id', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Graph API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Microsoft Graph token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_teams': return this.apiGet('/me/joinedTeams', params);
|
||||
case 'list_channels': return this.apiGet(`/teams/${params.team_id}/channels`, params, ['team_id']);
|
||||
case 'get_messages': return this.apiGet(`/teams/${params.team_id}/channels/${params.channel_id}/messages`, params, ['team_id', 'channel_id']);
|
||||
case 'send_message': return this.sendChannelMessage(params);
|
||||
case 'list_chats': return this.apiGet('/me/chats', params);
|
||||
case 'send_chat_message': return this.sendChatMessage(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendChannelMessage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/teams/${params.team_id}/channels/${params.channel_id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
body: { contentType: 'html', content: String(params.content) },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendChatMessage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/chats/${params.chat_id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({
|
||||
body: { contentType: 'html', content: String(params.content) },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
285
packages/agent/src/connectors/notion-connector.ts
Normal file
285
packages/agent/src/connectors/notion-connector.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Notion Connector — search, read, and manage Notion pages and databases.
|
||||
* Auth: Bearer (Integration Token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.notion.com/v1';
|
||||
const NOTION_VERSION = '2022-06-28';
|
||||
|
||||
export class NotionConnector extends BaseConnector {
|
||||
readonly id = 'notion';
|
||||
readonly name = 'Notion';
|
||||
readonly description = "Search, read, create, and update Notion pages and databases. Supports block-level content manipulation, database queries, and property updates across your workspace.";
|
||||
readonly service = 'notion.so';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/notion.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Create an Internal Integration at notion.so/my-integrations and share the relevant pages with it.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'search_pages',
|
||||
description: 'Search across all pages and databases in Notion',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query text' },
|
||||
filter: { type: 'string', enum: ['page', 'database'], description: 'Filter by object type' },
|
||||
page_size: { type: 'number', description: 'Number of results (max 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_page',
|
||||
description: 'Get a Notion page by ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page_id: { type: 'string', description: 'Page ID (UUID)' },
|
||||
},
|
||||
required: ['page_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_databases',
|
||||
description: 'List all databases the integration has access to',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page_size: { type: 'number', description: 'Number of results (max 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'query_database',
|
||||
description: 'Query a Notion database with optional filters and sorts',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
database_id: { type: 'string', description: 'Database ID (UUID)' },
|
||||
filter: { type: 'object', description: 'Notion filter object' },
|
||||
sorts: { type: 'array', description: 'Array of sort objects' },
|
||||
page_size: { type: 'number', description: 'Number of results (max 100)' },
|
||||
},
|
||||
required: ['database_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_page',
|
||||
description: 'Create a new Notion page in a parent page or database',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
parent_id: { type: 'string', description: 'Parent page or database ID' },
|
||||
parent_type: { type: 'string', enum: ['page_id', 'database_id'], description: 'Type of parent (default: page_id)' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
content: { type: 'string', description: 'Page content as plain text (converted to paragraph blocks)' },
|
||||
properties: { type: 'object', description: 'Additional database properties (when parent is a database)' },
|
||||
},
|
||||
required: ['parent_id', 'title'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_page',
|
||||
description: 'Update properties of an existing Notion page',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page_id: { type: 'string', description: 'Page ID (UUID)' },
|
||||
properties: { type: 'object', description: 'Properties to update' },
|
||||
archived: { type: 'boolean', description: 'Set to true to archive the page' },
|
||||
},
|
||||
required: ['page_id'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'get_block_children',
|
||||
description: 'Get the content blocks of a page or block',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
block_id: { type: 'string', description: 'Block or page ID (UUID)' },
|
||||
page_size: { type: 'number', description: 'Number of results (max 100)' },
|
||||
},
|
||||
required: ['block_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Notion API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Notion integration token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'search_pages': return this.searchPages(params);
|
||||
case 'get_page': return this.apiGet(`/pages/${params.page_id}`);
|
||||
case 'list_databases': return this.searchPages({ ...params, filter: 'database' });
|
||||
case 'query_database': return this.queryDatabase(params);
|
||||
case 'create_page': return this.createPage(params);
|
||||
case 'update_page': return this.updatePage(params);
|
||||
case 'get_block_children': return this.apiGet(`/blocks/${params.block_id}/children`, params, ['block_id']);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Notion-Version': NOTION_VERSION,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown> = {}, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined && typeof v === 'string') query.set(k, v);
|
||||
if (!stripKeys.includes(k) && v !== undefined && typeof v === 'number') query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Notion API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchPages(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (params.query) body.query = params.query;
|
||||
if (params.filter) body.filter = { value: params.filter, property: 'object' };
|
||||
if (params.page_size) body.page_size = params.page_size;
|
||||
|
||||
const res = await fetch(`${API_BASE}/search`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Notion API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async queryDatabase(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (params.filter) body.filter = params.filter;
|
||||
if (params.sorts) body.sorts = params.sorts;
|
||||
if (params.page_size) body.page_size = params.page_size;
|
||||
|
||||
const res = await fetch(`${API_BASE}/databases/${params.database_id}/query`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Notion API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createPage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const parentType = (params.parent_type as string) ?? 'page_id';
|
||||
const body: Record<string, unknown> = {
|
||||
parent: { [parentType]: params.parent_id },
|
||||
properties: {
|
||||
title: {
|
||||
title: [{ text: { content: params.title as string } }],
|
||||
},
|
||||
...(params.properties as Record<string, unknown> ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Add content as paragraph blocks if provided
|
||||
if (params.content) {
|
||||
body.children = [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
paragraph: {
|
||||
rich_text: [{ type: 'text', text: { content: params.content as string } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/pages`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Notion API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (params.properties) body.properties = params.properties;
|
||||
if (params.archived !== undefined) body.archived = params.archived;
|
||||
|
||||
const res = await fetch(`${API_BASE}/pages/${params.page_id}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Notion API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
345
packages/agent/src/connectors/obsidian-connector.ts
Normal file
345
packages/agent/src/connectors/obsidian-connector.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Obsidian Connector — read, search, and manage notes in a local Obsidian vault.
|
||||
* Auth: api_key (vault directory path stored as the credential)
|
||||
*
|
||||
* This is a LOCAL file-based connector — it uses fs/path, not HTTP.
|
||||
* The "api_key" credential is the absolute path to the Obsidian vault directory.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
export class ObsidianConnector extends BaseConnector {
|
||||
readonly id = 'obsidian';
|
||||
readonly name = 'Obsidian';
|
||||
readonly description = "Read and manage local Obsidian vault files. Search notes, read markdown content, and navigate the knowledge graph of your personal or team vault.";
|
||||
readonly service = 'local';
|
||||
readonly authType = 'api_key' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/obsidian.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Install the Local REST API community plugin in Obsidian and enable it to get the API key.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Search notes by filename or content (simple text matching)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query — matches against file names and content' },
|
||||
folder: { type: 'string', description: 'Limit search to a specific folder (relative path)' },
|
||||
limit: { type: 'number', description: 'Max results (default 20)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_note',
|
||||
description: 'Read the contents of a specific note',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note file (e.g., "Projects/my-note.md")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_notes',
|
||||
description: 'List all markdown files in the vault or a subfolder',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
folder: { type: 'string', description: 'Subfolder to list (relative path, default: vault root)' },
|
||||
limit: { type: 'number', description: 'Max results (default 100)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new markdown note in the vault',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path for the note (e.g., "Projects/new-note.md")' },
|
||||
content: { type: 'string', description: 'Note content (markdown)' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_note',
|
||||
description: 'Update (overwrite) the contents of an existing note',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note (e.g., "Projects/my-note.md")' },
|
||||
content: { type: 'string', description: 'New note content (markdown)' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_folders',
|
||||
description: 'List folders in the vault or a subfolder',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
folder: { type: 'string', description: 'Parent folder (relative path, default: vault root)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private vaultPath: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.vaultPath = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.vaultPath ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.vaultPath) {
|
||||
try {
|
||||
fs.accessSync(this.vaultPath, fs.constants.R_OK);
|
||||
const stat = fs.statSync(this.vaultPath);
|
||||
if (!stat.isDirectory()) {
|
||||
health.status = 'error';
|
||||
health.error = 'Vault path exists but is not a directory';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.vaultPath) return { success: false, error: 'Not connected — add Obsidian vault directory path in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'search_notes': return this.searchNotes(params);
|
||||
case 'get_note': return this.getNote(params);
|
||||
case 'list_notes': return this.listNotes(params);
|
||||
case 'create_note': return this.createNote(params);
|
||||
case 'update_note': return this.updateNote(params);
|
||||
case 'list_folders': return this.listFolders(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a relative path safely within the vault directory */
|
||||
private resolveSafe(relativePath: string): string | null {
|
||||
const resolved = path.resolve(this.vaultPath!, relativePath);
|
||||
// Guard against path traversal
|
||||
if (!resolved.startsWith(this.vaultPath!)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Recursively collect all .md files under a directory */
|
||||
private collectMarkdownFiles(dir: string, limit: number): string[] {
|
||||
const results: string[] = [];
|
||||
const walk = (d: string) => {
|
||||
if (results.length >= limit) return;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(d, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (results.length >= limit) return;
|
||||
const fullPath = path.join(d, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skip hidden directories (e.g., .obsidian, .trash)
|
||||
if (!entry.name.startsWith('.')) walk(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return results;
|
||||
}
|
||||
|
||||
private searchNotes(params: Record<string, unknown>): ConnectorResult {
|
||||
try {
|
||||
const query = (params.query as string).toLowerCase();
|
||||
const limit = (params.limit as number) ?? 20;
|
||||
const searchDir = params.folder
|
||||
? this.resolveSafe(params.folder as string)
|
||||
: this.vaultPath!;
|
||||
|
||||
if (!searchDir) return { success: false, error: 'Invalid folder path' };
|
||||
|
||||
const allFiles = this.collectMarkdownFiles(searchDir, 1000); // scan up to 1000 files
|
||||
const matches: Array<{ path: string; name: string; snippet: string }> = [];
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
if (matches.length >= limit) break;
|
||||
|
||||
const relativePath = path.relative(this.vaultPath!, filePath).replace(/\\/g, '/');
|
||||
const fileName = path.basename(filePath, '.md').toLowerCase();
|
||||
|
||||
// Check filename match
|
||||
if (fileName.includes(query)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const snippet = content.slice(0, 200);
|
||||
matches.push({ path: relativePath, name: path.basename(filePath), snippet });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check content match
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lowerContent = content.toLowerCase();
|
||||
const idx = lowerContent.indexOf(query);
|
||||
if (idx !== -1) {
|
||||
const start = Math.max(0, idx - 50);
|
||||
const end = Math.min(content.length, idx + query.length + 150);
|
||||
const snippet = (start > 0 ? '...' : '') + content.slice(start, end) + (end < content.length ? '...' : '');
|
||||
matches.push({ path: relativePath, name: path.basename(filePath), snippet });
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: { results: matches, total: matches.length } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private getNote(params: Record<string, unknown>): ConnectorResult {
|
||||
try {
|
||||
const notePath = this.resolveSafe(params.path as string);
|
||||
if (!notePath) return { success: false, error: 'Invalid path — path traversal not allowed' };
|
||||
|
||||
if (!fs.existsSync(notePath)) return { success: false, error: `Note not found: ${params.path}` };
|
||||
|
||||
const content = fs.readFileSync(notePath, 'utf-8');
|
||||
const stat = fs.statSync(notePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: (params.path as string).replace(/\\/g, '/'),
|
||||
name: path.basename(notePath),
|
||||
content,
|
||||
size: stat.size,
|
||||
modified: stat.mtime.toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private listNotes(params: Record<string, unknown>): ConnectorResult {
|
||||
try {
|
||||
const limit = (params.limit as number) ?? 100;
|
||||
const listDir = params.folder
|
||||
? this.resolveSafe(params.folder as string)
|
||||
: this.vaultPath!;
|
||||
|
||||
if (!listDir) return { success: false, error: 'Invalid folder path' };
|
||||
|
||||
const allFiles = this.collectMarkdownFiles(listDir, limit);
|
||||
const notes = allFiles.map(filePath => {
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
path: path.relative(this.vaultPath!, filePath).replace(/\\/g, '/'),
|
||||
name: path.basename(filePath),
|
||||
size: stat.size,
|
||||
modified: stat.mtime.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, data: { notes, total: notes.length } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private createNote(params: Record<string, unknown>): ConnectorResult {
|
||||
try {
|
||||
const notePath = this.resolveSafe(params.path as string);
|
||||
if (!notePath) return { success: false, error: 'Invalid path — path traversal not allowed' };
|
||||
|
||||
if (fs.existsSync(notePath)) return { success: false, error: `Note already exists: ${params.path}` };
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(notePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(notePath, params.content as string, 'utf-8');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: (params.path as string).replace(/\\/g, '/'),
|
||||
name: path.basename(notePath),
|
||||
created: true,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private updateNote(params: Record<string, unknown>): ConnectorResult {
|
||||
try {
|
||||
const notePath = this.resolveSafe(params.path as string);
|
||||
if (!notePath) return { success: false, error: 'Invalid path — path traversal not allowed' };
|
||||
|
||||
if (!fs.existsSync(notePath)) return { success: false, error: `Note not found: ${params.path}` };
|
||||
|
||||
fs.writeFileSync(notePath, params.content as string, 'utf-8');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: (params.path as string).replace(/\\/g, '/'),
|
||||
name: path.basename(notePath),
|
||||
updated: true,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private listFolders(params: Record<string, unknown>): ConnectorResult {
|
||||
try {
|
||||
const listDir = params.folder
|
||||
? this.resolveSafe(params.folder as string)
|
||||
: this.vaultPath!;
|
||||
|
||||
if (!listDir) return { success: false, error: 'Invalid folder path' };
|
||||
|
||||
const entries = fs.readdirSync(listDir, { withFileTypes: true });
|
||||
const folders = entries
|
||||
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map(e => ({
|
||||
name: e.name,
|
||||
path: path.relative(this.vaultPath!, path.join(listDir, e.name)).replace(/\\/g, '/'),
|
||||
}));
|
||||
|
||||
return { success: true, data: { folders, total: folders.length } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
211
packages/agent/src/connectors/onedrive-connector.ts
Normal file
211
packages/agent/src/connectors/onedrive-connector.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* OneDrive Connector — access files, search, and upload via Microsoft Graph API.
|
||||
* Auth: Bearer (Microsoft Graph API access token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
export class OneDriveConnector extends BaseConnector {
|
||||
readonly id = 'onedrive';
|
||||
readonly name = 'OneDrive';
|
||||
readonly description = "Browse, read, and manage OneDrive files and folders. Supports file listing, content reading, upload, and sharing across personal and business accounts.";
|
||||
readonly service = 'onedrive.live.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/microsoftonedrive.svg';
|
||||
readonly category = 'storage' as const;
|
||||
readonly setupGuide = "Register an app in Azure AD with Files permissions and use OAuth2 flow.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_files',
|
||||
description: 'List files and folders in the root of OneDrive',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
folder_path: { type: 'string', description: 'Folder path relative to root (e.g., "Documents/Work"). Omit for root.' },
|
||||
$top: { type: 'number', description: 'Max items to return (default 50)' },
|
||||
$orderby: { type: 'string', description: 'Order by field (e.g., "lastModifiedDateTime desc")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_file',
|
||||
description: 'Get file content by item ID (text files only, max 10MB)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
item_id: { type: 'string', description: 'OneDrive item ID' },
|
||||
},
|
||||
required: ['item_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_files',
|
||||
description: 'Search files and folders by name or content',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
$top: { type: 'number', description: 'Max results to return (default 25)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'upload_file',
|
||||
description: 'Upload a text file to OneDrive (max 4MB)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Destination path including filename (e.g., "Documents/notes.txt")' },
|
||||
content: { type: 'string', description: 'File content to upload (text only)' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_recent',
|
||||
description: 'List recently accessed files',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
$top: { type: 'number', description: 'Max items to return (default 25)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me/drive`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Graph API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Microsoft Graph token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_files': return this.listFiles(params);
|
||||
case 'get_file': return this.getFile(params);
|
||||
case 'search_files': return this.searchFiles(params);
|
||||
case 'upload_file': return this.uploadFile(params);
|
||||
case 'list_recent': return this.apiGet('/me/drive/recent', params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listFiles(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const folderPath = params.folder_path as string | undefined;
|
||||
const path = folderPath
|
||||
? `/me/drive/root:/${folderPath}:/children`
|
||||
: '/me/drive/root/children';
|
||||
return this.apiGet(path, params, ['folder_path']);
|
||||
}
|
||||
|
||||
private async getFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me/drive/items/${params.item_id}/content`, {
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
|
||||
const content = await res.text();
|
||||
if (content.length > 10 * 1024 * 1024) {
|
||||
return { success: false, error: 'File too large (>10MB) — use OneDrive directly for large files' };
|
||||
}
|
||||
return { success: true, data: { content } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchFiles(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const query = String(params.query);
|
||||
const searchParams: Record<string, unknown> = {};
|
||||
if (params.$top !== undefined) searchParams.$top = params.$top;
|
||||
return this.apiGet(`/me/drive/root/search(q='${encodeURIComponent(query)}')`, searchParams);
|
||||
}
|
||||
|
||||
private async uploadFile(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const filePath = String(params.path);
|
||||
const content = String(params.content);
|
||||
|
||||
if (content.length > 4 * 1024 * 1024) {
|
||||
return { success: false, error: 'Content too large (>4MB) — use upload session for large files' };
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/me/drive/root:/${filePath}:/content`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: content,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
292
packages/agent/src/connectors/onenote-connector.ts
Normal file
292
packages/agent/src/connectors/onenote-connector.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* OneNote Connector — notebooks, sections, and pages via Microsoft Graph API.
|
||||
*
|
||||
* E-6 — final Graph API harvest surface. Email + calendar live in
|
||||
* OutlookConnector, personal files in OneDriveConnector, Teams chat in
|
||||
* MSTeamsConnector. OneNote is the missing piece: it's where Microsoft
|
||||
* 365 knowledge workers keep their notes, meeting agendas, and shared
|
||||
* documentation — first-class harvest material.
|
||||
*
|
||||
* Auth: Bearer (Microsoft Graph token, same as Outlook/OneDrive/Teams).
|
||||
* Scopes required: Notes.Read or Notes.Read.All.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
export class OneNoteConnector extends BaseConnector {
|
||||
readonly id = 'onenote';
|
||||
readonly name = 'Microsoft OneNote';
|
||||
readonly description =
|
||||
'Read OneNote notebooks, sections, and pages. Harvest meeting notes, knowledge bases, and shared documentation from Microsoft 365.';
|
||||
readonly service = 'onenote.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl =
|
||||
'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/microsoftonenote.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide =
|
||||
'Register an app in Azure AD with Notes.Read (or Notes.Read.All for shared notebooks) permissions and use OAuth2 flow.';
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_notebooks',
|
||||
description: 'List all notebooks the user has access to',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
$top: { type: 'number', description: 'Max notebooks to return (default 25)' },
|
||||
$select: {
|
||||
type: 'string',
|
||||
description: 'Fields to select (e.g., "id,displayName,createdDateTime")',
|
||||
},
|
||||
$orderby: {
|
||||
type: 'string',
|
||||
description: 'Order by field (default "lastModifiedDateTime desc")',
|
||||
},
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_sections',
|
||||
description: 'List sections in a notebook',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
notebook_id: { type: 'string', description: 'Notebook ID (from list_notebooks)' },
|
||||
$top: { type: 'number', description: 'Max sections to return (default 25)' },
|
||||
},
|
||||
required: ['notebook_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_pages',
|
||||
description: 'List pages in a section, or across the whole user',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
section_id: {
|
||||
type: 'string',
|
||||
description: 'Section ID (optional — omit to list all pages user-wide)',
|
||||
},
|
||||
$top: { type: 'number', description: 'Max pages to return (default 25)' },
|
||||
$select: {
|
||||
type: 'string',
|
||||
description: 'Fields to select (e.g., "id,title,createdDateTime,lastModifiedDateTime")',
|
||||
},
|
||||
$orderby: {
|
||||
type: 'string',
|
||||
description: 'Order by field (default "lastModifiedDateTime desc")',
|
||||
},
|
||||
$filter: {
|
||||
type: 'string',
|
||||
description:
|
||||
'OData filter (e.g., "lastModifiedDateTime ge 2026-01-01T00:00:00Z")',
|
||||
},
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_page',
|
||||
description: 'Get a page’s HTML content (for harvest ingestion)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
page_id: { type: 'string', description: 'Page ID (from list_pages)' },
|
||||
includeIDs: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Include data-id attributes in the HTML for element-level edits (default false)',
|
||||
},
|
||||
},
|
||||
required: ['page_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_pages',
|
||||
description: 'Search pages by keyword across the user’s OneNote',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Free-text query (matches title + body)',
|
||||
},
|
||||
$top: { type: 'number', description: 'Max results (default 25)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
// Probing /me/onenote/notebooks?$top=1 is the cheapest endpoint
|
||||
// that exercises the OneNote scope specifically — /me alone
|
||||
// doesn't tell us the token has Notes.Read.
|
||||
const res = await fetch(`${API_BASE}/me/onenote/notebooks?$top=1`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Graph API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not connected — add Microsoft Graph token (with Notes.Read scope) in vault',
|
||||
};
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'list_notebooks':
|
||||
return this.apiGet('/me/onenote/notebooks', params);
|
||||
case 'list_sections':
|
||||
return this.listSections(params);
|
||||
case 'list_pages':
|
||||
return this.listPages(params);
|
||||
case 'get_page':
|
||||
return this.getPage(params);
|
||||
case 'search_pages':
|
||||
return this.searchPages(params);
|
||||
default:
|
||||
return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OData query string from the action params. `stripKeys` are
|
||||
* path-binding params (e.g. notebook_id) that should NOT propagate to
|
||||
* the query string — they're already consumed by the URL builder.
|
||||
*/
|
||||
private buildQuery(
|
||||
params: Record<string, unknown>,
|
||||
stripKeys: string[] = [],
|
||||
): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (stripKeys.includes(k)) continue;
|
||||
if (v === undefined || v === null) continue;
|
||||
query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
private async apiGet(
|
||||
path: string,
|
||||
params: Record<string, unknown>,
|
||||
stripKeys: string[] = [],
|
||||
): Promise<ConnectorResult> {
|
||||
try {
|
||||
const url = `${API_BASE}${path}${this.buildQuery(params, stripKeys)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listSections(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const id = String(params.notebook_id ?? '');
|
||||
if (!id) return { success: false, error: 'notebook_id is required' };
|
||||
return this.apiGet(`/me/onenote/notebooks/${encodeURIComponent(id)}/sections`, params, [
|
||||
'notebook_id',
|
||||
]);
|
||||
}
|
||||
|
||||
private async listPages(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const section = params.section_id;
|
||||
if (typeof section === 'string' && section.length > 0) {
|
||||
return this.apiGet(
|
||||
`/me/onenote/sections/${encodeURIComponent(section)}/pages`,
|
||||
params,
|
||||
['section_id'],
|
||||
);
|
||||
}
|
||||
// User-wide page listing — useful for "most recently modified
|
||||
// across all notebooks" harvest queries.
|
||||
return this.apiGet('/me/onenote/pages', params);
|
||||
}
|
||||
|
||||
private async getPage(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const id = String(params.page_id ?? '');
|
||||
if (!id) return { success: false, error: 'page_id is required' };
|
||||
try {
|
||||
const includeIDs = params.includeIDs === true ? '?includeIDs=true' : '';
|
||||
const url = `${API_BASE}/me/onenote/pages/${encodeURIComponent(id)}/content${includeIDs}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
// Page content is HTML, not JSON — return as text for the harvest
|
||||
// pipeline to parse/render.
|
||||
const html = await res.text();
|
||||
return { success: true, data: { html, contentType: res.headers.get('content-type') } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchPages(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
// OneNote's search is via $search on /me/onenote/pages — same shape
|
||||
// as Outlook's search_emails (quoted to allow phrase search).
|
||||
const query = String(params.query ?? '');
|
||||
if (!query) return { success: false, error: 'query is required' };
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('$search', `"${query}"`);
|
||||
if (params.$top !== undefined) qs.set('$top', String(params.$top));
|
||||
const url = `${API_BASE}/me/onenote/pages?${qs.toString()}`;
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
272
packages/agent/src/connectors/outlook-connector.ts
Normal file
272
packages/agent/src/connectors/outlook-connector.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Outlook Connector — calendar events and email via Microsoft Graph API.
|
||||
* Auth: Bearer (Microsoft Graph API access token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
export class OutlookConnector extends BaseConnector {
|
||||
readonly id = 'outlook';
|
||||
readonly name = 'Outlook Calendar & Email';
|
||||
readonly description = "Read, search, and send Outlook/Microsoft 365 email. Supports folder browsing, message threading, attachment handling, and full-text inbox search.";
|
||||
readonly service = 'outlook.office365.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/microsoftoutlook.svg';
|
||||
readonly category = 'communication' as const;
|
||||
// Auto-fetch: list_emails is read-only — safe to harvest recent inbox messages
|
||||
// into memory on a PRO schedule. We pin `$select` to metadata + the short
|
||||
// bodyPreview (NOT the full message body) so durable, model-visible memory
|
||||
// frames don't persist entire email bodies (less secret/PII exposure). (gmail is
|
||||
// NOT wired: its list_messages returns id-stubs only — needs list→get enrichment.)
|
||||
readonly harvestAction = {
|
||||
action: 'list_emails',
|
||||
params: { $select: 'subject,from,receivedDateTime,bodyPreview' },
|
||||
};
|
||||
readonly setupGuide = "Register an app in Azure AD with Mail permissions and use OAuth2 flow.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_events',
|
||||
description: 'List upcoming calendar events',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
$top: { type: 'number', description: 'Max events to return (default 25)' },
|
||||
$orderby: { type: 'string', description: 'Order by field (default "start/dateTime")' },
|
||||
$filter: { type: 'string', description: 'OData filter expression (e.g., "start/dateTime ge \'2026-01-01\'")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_event',
|
||||
description: 'Create a new calendar event',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
subject: { type: 'string', description: 'Event subject/title' },
|
||||
start: { type: 'string', description: 'Start datetime in ISO 8601 (e.g., "2026-03-20T10:00:00")' },
|
||||
end: { type: 'string', description: 'End datetime in ISO 8601 (e.g., "2026-03-20T11:00:00")' },
|
||||
timeZone: { type: 'string', description: 'Time zone (default "UTC")' },
|
||||
body: { type: 'string', description: 'Event body/description (HTML supported)' },
|
||||
location: { type: 'string', description: 'Event location' },
|
||||
attendees: { type: 'array', items: { type: 'string' }, description: 'Attendee email addresses' },
|
||||
isOnlineMeeting: { type: 'boolean', description: 'Create as online meeting (default false)' },
|
||||
},
|
||||
required: ['subject', 'start', 'end'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_emails',
|
||||
description: 'List recent emails from inbox',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
$top: { type: 'number', description: 'Max emails to return (default 25)' },
|
||||
$filter: { type: 'string', description: 'OData filter (e.g., "isRead eq false")' },
|
||||
$orderby: { type: 'string', description: 'Order by field (default "receivedDateTime desc")' },
|
||||
$select: { type: 'string', description: 'Fields to select (e.g., "subject,from,receivedDateTime")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'send_email',
|
||||
description: 'Send an email',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
to: { type: 'array', items: { type: 'string' }, description: 'Recipient email addresses' },
|
||||
subject: { type: 'string', description: 'Email subject' },
|
||||
body: { type: 'string', description: 'Email body (HTML supported)' },
|
||||
cc: { type: 'array', items: { type: 'string' }, description: 'CC email addresses' },
|
||||
importance: { type: 'string', enum: ['low', 'normal', 'high'], description: 'Email importance (default "normal")' },
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_emails',
|
||||
description: 'Search emails by keyword',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query (searches subject, body, and sender)' },
|
||||
$top: { type: 'number', description: 'Max results to return (default 25)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_email',
|
||||
description: 'Get a specific email by ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
message_id: { type: 'string', description: 'Email message ID' },
|
||||
$select: { type: 'string', description: 'Fields to select' },
|
||||
},
|
||||
required: ['message_id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Graph API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Microsoft Graph token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_events': return this.apiGet('/me/events', params);
|
||||
case 'create_event': return this.createEvent(params);
|
||||
case 'list_emails': return this.apiGet('/me/messages', params);
|
||||
case 'send_email': return this.sendEmail(params);
|
||||
case 'search_emails': return this.searchEmails(params);
|
||||
case 'get_email': return this.apiGet(`/me/messages/${params.message_id}`, params, ['message_id']);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const url = `${API_BASE}${path}${qs ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createEvent(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const tz = (params.timeZone as string) ?? 'UTC';
|
||||
const body: Record<string, unknown> = {
|
||||
subject: params.subject,
|
||||
start: { dateTime: params.start, timeZone: tz },
|
||||
end: { dateTime: params.end, timeZone: tz },
|
||||
};
|
||||
if (params.body) {
|
||||
body.body = { contentType: 'html', content: String(params.body) };
|
||||
}
|
||||
if (params.location) {
|
||||
body.location = { displayName: String(params.location) };
|
||||
}
|
||||
if (Array.isArray(params.attendees)) {
|
||||
body.attendees = (params.attendees as string[]).map(email => ({
|
||||
emailAddress: { address: email },
|
||||
type: 'required',
|
||||
}));
|
||||
}
|
||||
if (params.isOnlineMeeting) {
|
||||
body.isOnlineMeeting = true;
|
||||
body.onlineMeetingProvider = 'teamsForBusiness';
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/me/events`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEmail(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const toRecipients = (params.to as string[]).map(email => ({
|
||||
emailAddress: { address: email },
|
||||
}));
|
||||
const message: Record<string, unknown> = {
|
||||
subject: params.subject,
|
||||
body: { contentType: 'html', content: String(params.body) },
|
||||
toRecipients,
|
||||
};
|
||||
if (Array.isArray(params.cc) && params.cc.length > 0) {
|
||||
message.ccRecipients = (params.cc as string[]).map(email => ({
|
||||
emailAddress: { address: email },
|
||||
}));
|
||||
}
|
||||
if (params.importance) {
|
||||
message.importance = params.importance;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/me/sendMail`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ message }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
// sendMail returns 202 Accepted with no body on success
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: { sent: true } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async searchEmails(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('$search', `"${String(params.query)}"`);
|
||||
if (params.$top !== undefined) query.set('$top', String(params.$top));
|
||||
const url = `${API_BASE}/me/messages?${query.toString()}`;
|
||||
const res = await fetch(url, { headers: this.headers(), signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Graph API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
201
packages/agent/src/connectors/pipedrive-connector.ts
Normal file
201
packages/agent/src/connectors/pipedrive-connector.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Pipedrive Connector — manage deals, persons, and activities.
|
||||
* Auth: API Key (passed as query parameter)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.pipedrive.com/v1';
|
||||
|
||||
export class PipedriveConnector extends BaseConnector {
|
||||
readonly id = 'pipedrive';
|
||||
readonly name = 'Pipedrive';
|
||||
readonly description = "Manage Pipedrive deals, contacts, organizations, and activities. Track pipeline stages, log calls and emails, and search your entire sales CRM.";
|
||||
readonly service = 'pipedrive.com';
|
||||
readonly authType = 'api_key' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/pipedrive.svg';
|
||||
readonly category = 'crm' as const;
|
||||
readonly setupGuide = "Get your Personal API Token from Pipedrive Settings > Personal Preferences > API.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_deals',
|
||||
description: 'List deals with optional filters',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['open', 'won', 'lost', 'deleted', 'all_not_deleted'], description: 'Deal status filter' },
|
||||
start: { type: 'number', description: 'Pagination start (default 0)' },
|
||||
limit: { type: 'number', description: 'Results per page (default 100)' },
|
||||
sort: { type: 'string', description: 'Sort field and order (e.g., "add_time DESC")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_deal',
|
||||
description: 'Get a single deal by ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
id: { type: 'number', description: 'Pipedrive deal ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_deal',
|
||||
description: 'Create a new deal',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Deal title' },
|
||||
value: { type: 'number', description: 'Deal value' },
|
||||
currency: { type: 'string', description: 'Currency code (e.g., "USD", "EUR")' },
|
||||
person_id: { type: 'number', description: 'Associated person ID' },
|
||||
org_id: { type: 'number', description: 'Associated organization ID' },
|
||||
stage_id: { type: 'number', description: 'Pipeline stage ID' },
|
||||
expected_close_date: { type: 'string', description: 'Expected close date (YYYY-MM-DD)' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'search_deals',
|
||||
description: 'Search deals by term',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
term: { type: 'string', description: 'Search term' },
|
||||
limit: { type: 'number', description: 'Max results (default 100)' },
|
||||
},
|
||||
required: ['term'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_persons',
|
||||
description: 'List persons (contacts)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
start: { type: 'number', description: 'Pagination start (default 0)' },
|
||||
limit: { type: 'number', description: 'Results per page (default 100)' },
|
||||
sort: { type: 'string', description: 'Sort field and order' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_person',
|
||||
description: 'Create a new person (contact)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Person full name' },
|
||||
email: { type: 'string', description: 'Email address' },
|
||||
phone: { type: 'string', description: 'Phone number' },
|
||||
org_id: { type: 'number', description: 'Associated organization ID' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_activities',
|
||||
description: 'List activities (calls, meetings, tasks)',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
start: { type: 'number', description: 'Pagination start (default 0)' },
|
||||
limit: { type: 'number', description: 'Results per page (default 100)' },
|
||||
type: { type: 'string', description: 'Activity type filter (e.g., "call", "meeting", "task")' },
|
||||
done: { type: 'number', enum: [0, 1], description: '0 = undone, 1 = done' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private apiToken: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.apiToken = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.apiToken ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.apiToken) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me?api_token=${this.apiToken}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Pipedrive API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.apiToken) return { success: false, error: 'Not connected — add Pipedrive API token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_deals': return this.apiGet('/deals', params);
|
||||
case 'get_deal': return this.apiGet(`/deals/${params.id}`, params, ['id']);
|
||||
case 'create_deal': return this.apiPost('/deals', params);
|
||||
case 'search_deals': return this.apiGet('/deals/search', params);
|
||||
case 'list_persons': return this.apiGet('/persons', params);
|
||||
case 'create_person': return this.apiPost('/persons', params);
|
||||
case 'list_activities': return this.apiGet('/activities', params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private buildUrl(path: string, params: Record<string, unknown>, stripKeys: string[] = []): string {
|
||||
const query = new URLSearchParams();
|
||||
query.set('api_token', this.apiToken!);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
return `${API_BASE}${path}?${query.toString()}`;
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown>, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const url = this.buildUrl(path, params, stripKeys);
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Pipedrive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPost(path: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const url = `${API_BASE}${path}?api_token=${this.apiToken}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Pipedrive API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
287
packages/agent/src/connectors/postgres-connector.ts
Normal file
287
packages/agent/src/connectors/postgres-connector.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* PostgreSQL Connector — execute SQL queries against a PostgreSQL database.
|
||||
* Auth: API Key (connection string, e.g., "postgresql://user:pass@host:5432/db")
|
||||
* Uses dynamic import for 'pg' — gracefully handles missing module.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
// ── Minimal shape of the optional `pg` module (only what we use) ──
|
||||
interface PgField { name: string; dataTypeID: number }
|
||||
interface PgQueryResult {
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number | null;
|
||||
command?: string;
|
||||
fields?: PgField[];
|
||||
}
|
||||
interface PgClient {
|
||||
connect(): Promise<void>;
|
||||
query(sql: string, params?: unknown[]): Promise<PgQueryResult>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
interface PgModule {
|
||||
Client: new (config: { connectionString: string | null }) => PgClient;
|
||||
}
|
||||
|
||||
export class PostgresConnector extends BaseConnector {
|
||||
readonly id = 'postgres';
|
||||
readonly name = 'PostgreSQL';
|
||||
readonly description = "Execute SQL queries against PostgreSQL databases. Supports SELECT queries, schema inspection, table listing, and parameterized queries with connection pooling.";
|
||||
readonly service = 'local';
|
||||
readonly authType = 'api_key' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/postgresql.svg';
|
||||
readonly category = 'data' as const;
|
||||
readonly setupGuide = "Provide a PostgreSQL connection string: postgresql://user:password@host:port/database";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'query',
|
||||
description: 'Run a SELECT query and return results',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
sql: { type: 'string', description: 'SQL SELECT query to execute' },
|
||||
params: { type: 'array', items: { type: 'string' }, description: 'Parameterized query values ($1, $2, ...)' },
|
||||
},
|
||||
required: ['sql'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'execute',
|
||||
description: 'Run an INSERT, UPDATE, or DELETE statement',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
sql: { type: 'string', description: 'SQL statement to execute' },
|
||||
params: { type: 'array', items: { type: 'string' }, description: 'Parameterized query values ($1, $2, ...)' },
|
||||
},
|
||||
required: ['sql'],
|
||||
},
|
||||
riskLevel: 'high',
|
||||
},
|
||||
{
|
||||
name: 'list_tables',
|
||||
description: 'List all tables in the current database schema',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
schema: { type: 'string', description: 'Schema name (default "public")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'describe_table',
|
||||
description: 'Show column names, types, and constraints for a table',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
table: { type: 'string', description: 'Table name' },
|
||||
schema: { type: 'string', description: 'Schema name (default "public")' },
|
||||
},
|
||||
required: ['table'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private connectionString: string | null = null;
|
||||
private pgModule: PgModule | null = null;
|
||||
private client: PgClient | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.connectionString = cred?.value ?? null;
|
||||
|
||||
// Try to dynamically import pg
|
||||
if (this.connectionString) {
|
||||
try {
|
||||
// pg is an optional dependency loaded at runtime; the dynamic specifier
|
||||
// is intentionally untyped (no @types/pg in this package's deps).
|
||||
this.pgModule = (await import('pg' as string)) as unknown as PgModule;
|
||||
} catch {
|
||||
this.pgModule = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.connectionString ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (!this.connectionString) return health;
|
||||
|
||||
if (!this.pgModule) {
|
||||
health.status = 'error';
|
||||
health.error = 'pg module not installed — run "npm install pg" to enable PostgreSQL connector';
|
||||
return health;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new this.pgModule.Client({ connectionString: this.connectionString });
|
||||
await client.connect();
|
||||
await client.query('SELECT 1');
|
||||
await client.end();
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.connectionString) {
|
||||
return { success: false, error: 'Not connected — add PostgreSQL connection string in vault' };
|
||||
}
|
||||
|
||||
if (!this.pgModule) {
|
||||
return { success: false, error: 'pg module not installed — run "npm install pg" to enable PostgreSQL connector' };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'query': return this.runQuery(params);
|
||||
case 'execute': return this.runExecute(params);
|
||||
case 'list_tables': return this.listTables(params);
|
||||
case 'describe_table': return this.describeTable(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient(): Promise<PgClient> {
|
||||
if (!this.pgModule) throw new Error('pg module not installed');
|
||||
const client = new this.pgModule.Client({ connectionString: this.connectionString });
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
private async runQuery(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
let client: PgClient | undefined;
|
||||
try {
|
||||
const sql = String(params.sql);
|
||||
// Safety check: only allow SELECT / WITH / EXPLAIN / SHOW
|
||||
const normalized = sql.trim().toUpperCase();
|
||||
if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH') && !normalized.startsWith('EXPLAIN') && !normalized.startsWith('SHOW')) {
|
||||
return { success: false, error: 'query action only supports SELECT, WITH, EXPLAIN, and SHOW statements. Use execute for mutations.' };
|
||||
}
|
||||
|
||||
client = await this.getClient();
|
||||
const queryParams = (params.params as string[]) ?? [];
|
||||
const result = await client.query(sql, queryParams);
|
||||
await client.end();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
fields: result.fields?.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
try { await client?.end(); } catch { /* ignore */ }
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async runExecute(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
let client: PgClient | undefined;
|
||||
try {
|
||||
const sql = String(params.sql);
|
||||
// Safety: block DROP DATABASE, TRUNCATE on system tables, etc.
|
||||
const normalized = sql.trim().toUpperCase();
|
||||
if (normalized.startsWith('DROP DATABASE') || normalized.startsWith('DROP SCHEMA')) {
|
||||
return { success: false, error: 'DROP DATABASE and DROP SCHEMA are blocked for safety' };
|
||||
}
|
||||
|
||||
client = await this.getClient();
|
||||
const queryParams = (params.params as string[]) ?? [];
|
||||
const result = await client.query(sql, queryParams);
|
||||
await client.end();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rowCount: result.rowCount,
|
||||
command: result.command,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
try { await client?.end(); } catch { /* ignore */ }
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listTables(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
let client: PgClient | undefined;
|
||||
try {
|
||||
const schema = String(params.schema ?? 'public');
|
||||
client = await this.getClient();
|
||||
const result = await client.query(
|
||||
`SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name`,
|
||||
[schema],
|
||||
);
|
||||
await client.end();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
tables: result.rows,
|
||||
schema,
|
||||
count: result.rowCount,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
try { await client?.end(); } catch { /* ignore */ }
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async describeTable(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
let client: PgClient | undefined;
|
||||
try {
|
||||
const table = String(params.table);
|
||||
const schema = String(params.schema ?? 'public');
|
||||
client = await this.getClient();
|
||||
|
||||
// Column info
|
||||
const columns = await client.query(
|
||||
`SELECT column_name, data_type, is_nullable, column_default, character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2
|
||||
ORDER BY ordinal_position`,
|
||||
[schema, table],
|
||||
);
|
||||
|
||||
// Primary key info
|
||||
const pk = await client.query(
|
||||
`SELECT kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.table_schema = $1 AND tc.table_name = $2 AND tc.constraint_type = 'PRIMARY KEY'
|
||||
ORDER BY kcu.ordinal_position`,
|
||||
[schema, table],
|
||||
);
|
||||
|
||||
await client.end();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
table,
|
||||
schema,
|
||||
columns: columns.rows,
|
||||
primaryKey: pk.rows.map((r) => r.column_name),
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
try { await client?.end(); } catch { /* ignore */ }
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
248
packages/agent/src/connectors/salesforce-connector.ts
Normal file
248
packages/agent/src/connectors/salesforce-connector.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Salesforce Connector — access records, contacts, and opportunities via REST API.
|
||||
* Auth: Bearer (OAuth2 access token or session token)
|
||||
* Requires instance URL stored in vault metadata.
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_VERSION = 'v59.0';
|
||||
|
||||
export class SalesforceConnector extends BaseConnector {
|
||||
readonly id = 'salesforce';
|
||||
readonly name = 'Salesforce';
|
||||
readonly description = "Query and manage Salesforce objects using SOQL. Access leads, contacts, opportunities, accounts, and custom objects with full CRM visibility.";
|
||||
readonly service = 'salesforce.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/salesforce.svg';
|
||||
readonly category = 'crm' as const;
|
||||
readonly setupGuide = "Create a Connected App in Salesforce Setup and use OAuth2 flow to get an access token.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search records using a SOQL query',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'SOQL query (e.g., "SELECT Id, Name FROM Account LIMIT 10")' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_contacts',
|
||||
description: 'List contacts with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
fields: { type: 'string', description: 'Comma-separated field names (default: Id,Name,Email,Phone)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'get_record',
|
||||
description: 'Get a single record by object type and ID',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
objectType: { type: 'string', description: 'Salesforce object type (e.g., "Contact", "Account", "Lead")' },
|
||||
recordId: { type: 'string', description: 'Salesforce record ID (18-char)' },
|
||||
fields: { type: 'string', description: 'Comma-separated field names to retrieve' },
|
||||
},
|
||||
required: ['objectType', 'recordId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_record',
|
||||
description: 'Create a new record of any object type',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
objectType: { type: 'string', description: 'Salesforce object type (e.g., "Contact", "Lead")' },
|
||||
fields: { type: 'object', description: 'Field name/value pairs for the new record' },
|
||||
},
|
||||
required: ['objectType', 'fields'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_record',
|
||||
description: 'Update an existing record',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
objectType: { type: 'string', description: 'Salesforce object type' },
|
||||
recordId: { type: 'string', description: 'Salesforce record ID' },
|
||||
fields: { type: 'object', description: 'Field name/value pairs to update' },
|
||||
},
|
||||
required: ['objectType', 'recordId', 'fields'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_opportunities',
|
||||
description: 'List opportunities with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
fields: { type: 'string', description: 'Comma-separated field names (default: Id,Name,StageName,Amount,CloseDate)' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
private instanceUrl: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
|
||||
// Instance URL from vault metadata (e.g., "https://mycompany.salesforce.com")
|
||||
const urlEntry = vault.get(`connector:${this.id}:instance_url`);
|
||||
this.instanceUrl = urlEntry?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token && this.instanceUrl ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token && this.instanceUrl) {
|
||||
try {
|
||||
const res = await fetch(`${this.instanceUrl}/services/data/${API_VERSION}/limits`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Salesforce API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token || !this.instanceUrl) {
|
||||
return { success: false, error: 'Not connected — add Salesforce access token and instance URL in vault' };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'search': return this.soqlQuery(params);
|
||||
case 'list_contacts': return this.listObjects('Contact', params, 'Id,Name,Email,Phone');
|
||||
case 'get_record': return this.getRecord(params);
|
||||
case 'create_record': return this.createRecord(params);
|
||||
case 'update_record': return this.updateRecord(params);
|
||||
case 'list_opportunities': return this.listObjects('Opportunity', params, 'Id,Name,StageName,Amount,CloseDate');
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private get apiBase(): string {
|
||||
return `${this.instanceUrl}/services/data/${API_VERSION}`;
|
||||
}
|
||||
|
||||
private async soqlQuery(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = encodeURIComponent(String(params.query));
|
||||
const res = await fetch(`${this.apiBase}/query?q=${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listObjects(objectType: string, params: Record<string, unknown>, defaultFields: string): Promise<ConnectorResult> {
|
||||
try {
|
||||
const limit = (params.limit as number) ?? 25;
|
||||
const fields = (params.fields as string) ?? defaultFields;
|
||||
const soql = `SELECT ${fields} FROM ${objectType} ORDER BY CreatedDate DESC LIMIT ${limit}`;
|
||||
const res = await fetch(`${this.apiBase}/query?q=${encodeURIComponent(soql)}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const objectType = String(params.objectType);
|
||||
const recordId = String(params.recordId);
|
||||
let url = `${this.apiBase}/sobjects/${objectType}/${recordId}`;
|
||||
if (params.fields) url += `?fields=${encodeURIComponent(String(params.fields))}`;
|
||||
const res = await fetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const objectType = String(params.objectType);
|
||||
const fields = params.fields as Record<string, unknown>;
|
||||
const res = await fetch(`${this.apiBase}/sobjects/${objectType}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(fields),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const objectType = String(params.objectType);
|
||||
const recordId = String(params.recordId);
|
||||
const fields = params.fields as Record<string, unknown>;
|
||||
const res = await fetch(`${this.apiBase}/sobjects/${objectType}/${recordId}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(fields),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
// Salesforce returns 204 No Content on successful update
|
||||
if (res.status !== 204 && !res.ok) {
|
||||
return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
}
|
||||
return { success: true, data: { id: recordId, updated: true } };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
162
packages/agent/src/connectors/slack-connector.ts
Normal file
162
packages/agent/src/connectors/slack-connector.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Slack Connector — list channels, read messages, search, and send messages.
|
||||
* Auth: Bearer (Bot User OAuth Token)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://slack.com/api';
|
||||
|
||||
export class SlackConnector extends BaseConnector {
|
||||
readonly id = 'slack';
|
||||
readonly name = 'Slack';
|
||||
readonly description = "Send messages, search conversations, read channels, and manage Slack workspaces. Supports all standard Slack messaging operations including DMs and channel posts.";
|
||||
readonly service = 'slack.com';
|
||||
readonly authType = 'bearer' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/slack.svg';
|
||||
readonly category = 'communication' as const;
|
||||
readonly setupGuide = "Create a Slack App at api.slack.com, add Bot Token Scopes, install to workspace, copy Bot User OAuth Token.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_channels',
|
||||
description: 'List Slack channels the bot has access to',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max channels to return (default 100)' },
|
||||
types: { type: 'string', description: 'Channel types: public_channel,private_channel' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'read_channel',
|
||||
description: 'Read recent messages from a channel',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
channel: { type: 'string', description: 'Channel ID' },
|
||||
limit: { type: 'number', description: 'Max messages to return (default 20)' },
|
||||
},
|
||||
required: ['channel'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_messages',
|
||||
description: 'Search Slack messages across all channels',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
count: { type: 'number', description: 'Number of results (default 20)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'send_message',
|
||||
description: 'Send a message to a Slack channel',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
channel: { type: 'string', description: 'Channel ID or name' },
|
||||
text: { type: 'string', description: 'Message text (markdown supported)' },
|
||||
},
|
||||
required: ['channel', 'text'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.token ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth.test`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; error?: string };
|
||||
if (!data.ok) {
|
||||
health.status = 'error';
|
||||
health.error = data.error ?? 'Auth test failed';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.token) return { success: false, error: 'Not connected — add Slack bot token in vault' };
|
||||
|
||||
switch (action) {
|
||||
case 'list_channels': return this.slackGet('conversations.list', params);
|
||||
case 'read_channel': return this.slackGet('conversations.history', params);
|
||||
case 'search_messages': return this.slackGet('search.messages', params);
|
||||
case 'send_message': return this.slackPost('chat.postMessage', params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
private async slackGet(method: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const res = await fetch(`${API_BASE}/${method}${qs ? `?${qs}` : ''}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; error?: string };
|
||||
if (!data.ok) return { success: false, error: data.error ?? `Slack API error: ${method}` };
|
||||
return { success: true, data };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async slackPost(method: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(params),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; error?: string };
|
||||
if (!data.ok) return { success: false, error: data.error ?? `Slack API error: ${method}` };
|
||||
return { success: true, data };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
270
packages/agent/src/connectors/trello-connector.ts
Normal file
270
packages/agent/src/connectors/trello-connector.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Trello Connector — manage boards, lists, and cards via REST API.
|
||||
* Auth: API key + token (query params)
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_BASE = 'https://api.trello.com/1';
|
||||
|
||||
export class TrelloConnector extends BaseConnector {
|
||||
readonly id = 'trello';
|
||||
readonly name = 'Trello';
|
||||
readonly description = "Manage Trello boards, lists, and cards. Create cards, move between lists, assign members, add labels, and search across all accessible boards.";
|
||||
readonly service = 'trello.com';
|
||||
readonly authType = 'api_key' as const;
|
||||
readonly substrate = 'waggle' as const;
|
||||
readonly logoUrl = 'https://cdn.jsdelivr.net/npm/simple-icons@v11/icons/trello.svg';
|
||||
readonly category = 'productivity' as const;
|
||||
readonly setupGuide = "Get your API Key at trello.com/app-key and generate a Token with write access.";
|
||||
|
||||
readonly actions: ConnectorAction[] = [
|
||||
{
|
||||
name: 'list_boards',
|
||||
description: 'List boards for the authenticated user',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
filter: { type: 'string', enum: ['all', 'open', 'closed', 'members', 'organization', 'public', 'starred'], description: 'Board filter (default "open")' },
|
||||
fields: { type: 'string', description: 'Comma-separated field names to return' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'list_cards',
|
||||
description: 'List cards on a board or in a list',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
boardId: { type: 'string', description: 'Board ID to list cards from' },
|
||||
listId: { type: 'string', description: 'List ID to list cards from (alternative to boardId)' },
|
||||
filter: { type: 'string', enum: ['all', 'open', 'closed'], description: 'Card filter (default "open")' },
|
||||
},
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'create_card',
|
||||
description: 'Create a new card in a list',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
idList: { type: 'string', description: 'List ID to create card in' },
|
||||
name: { type: 'string', description: 'Card name/title' },
|
||||
desc: { type: 'string', description: 'Card description (markdown)' },
|
||||
pos: { type: 'string', description: 'Position: "top", "bottom", or a number' },
|
||||
due: { type: 'string', description: 'Due date (ISO format)' },
|
||||
idLabels: { type: 'string', description: 'Comma-separated label IDs' },
|
||||
idMembers: { type: 'string', description: 'Comma-separated member IDs' },
|
||||
},
|
||||
required: ['idList', 'name'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'update_card',
|
||||
description: 'Update an existing Trello card',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
cardId: { type: 'string', description: 'Card ID to update' },
|
||||
name: { type: 'string', description: 'New card name' },
|
||||
desc: { type: 'string', description: 'New description' },
|
||||
closed: { type: 'boolean', description: 'Archive the card (true/false)' },
|
||||
idList: { type: 'string', description: 'Move card to a different list' },
|
||||
due: { type: 'string', description: 'New due date (ISO format)' },
|
||||
pos: { type: 'string', description: 'New position: "top", "bottom", or a number' },
|
||||
},
|
||||
required: ['cardId'],
|
||||
},
|
||||
riskLevel: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'list_lists',
|
||||
description: 'List all lists on a board',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
boardId: { type: 'string', description: 'Board ID' },
|
||||
filter: { type: 'string', enum: ['all', 'open', 'closed'], description: 'List filter (default "open")' },
|
||||
},
|
||||
required: ['boardId'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
name: 'search_cards',
|
||||
description: 'Search cards across boards',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query text' },
|
||||
idBoards: { type: 'string', description: 'Comma-separated board IDs to limit search (or "mine")' },
|
||||
cards_limit: { type: 'number', description: 'Max card results (default 10, max 1000)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
private apiKey: string | null = null;
|
||||
private apiToken: string | null = null;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.apiToken = cred?.value ?? null;
|
||||
// API key stored as a separate vault entry
|
||||
const keyEntry = vault.get(`connector:${this.id}:api_key`);
|
||||
this.apiKey = keyEntry?.value ?? null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
const health: ConnectorHealth = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
status: this.apiKey && this.apiToken ? 'connected' : 'disconnected',
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (this.apiKey && this.apiToken) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/members/me?${this.authParams()}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Trello API returned ${res.status}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
health.status = 'error';
|
||||
health.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
async execute(action: string, params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
if (!this.apiKey || !this.apiToken) {
|
||||
return { success: false, error: 'Not connected — add Trello API key and token in vault' };
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'list_boards': return this.listBoards(params);
|
||||
case 'list_cards': return this.listCards(params);
|
||||
case 'create_card': return this.createCard(params);
|
||||
case 'update_card': return this.updateCard(params);
|
||||
case 'list_lists': return this.listLists(params);
|
||||
case 'search_cards': return this.searchCards(params);
|
||||
default: return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build auth query parameter string */
|
||||
private authParams(): string {
|
||||
return `key=${encodeURIComponent(this.apiKey!)}&token=${encodeURIComponent(this.apiToken!)}`;
|
||||
}
|
||||
|
||||
private async apiGet(path: string, params: Record<string, unknown> = {}, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const authQs = this.authParams();
|
||||
const sep = qs ? `&${qs}` : '';
|
||||
const url = `${API_BASE}${path}?${authQs}${sep}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Trello API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPost(path: string, params: Record<string, unknown> = {}, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
// Trello POST uses query params for auth and form data for body, but simple approach: all as query params
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const authQs = this.authParams();
|
||||
const sep = qs ? `&${qs}` : '';
|
||||
const url = `${API_BASE}${path}?${authQs}${sep}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Trello API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async apiPut(path: string, params: Record<string, unknown> = {}, stripKeys: string[] = []): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!stripKeys.includes(k) && v !== undefined) query.set(k, String(v));
|
||||
}
|
||||
const qs = query.toString();
|
||||
const authQs = this.authParams();
|
||||
const sep = qs ? `&${qs}` : '';
|
||||
const url = `${API_BASE}${path}?${authQs}${sep}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Trello API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async listBoards(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
if (params.filter) queryParams.filter = params.filter;
|
||||
if (params.fields) queryParams.fields = params.fields;
|
||||
return this.apiGet('/members/me/boards', queryParams);
|
||||
}
|
||||
|
||||
private async listCards(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
if (params.filter) queryParams.filter = params.filter;
|
||||
if (params.listId) {
|
||||
return this.apiGet(`/lists/${encodeURIComponent(String(params.listId))}/cards`, queryParams, ['listId']);
|
||||
}
|
||||
if (params.boardId) {
|
||||
return this.apiGet(`/boards/${encodeURIComponent(String(params.boardId))}/cards`, queryParams, ['boardId']);
|
||||
}
|
||||
return { success: false, error: 'Provide boardId or listId to list cards' };
|
||||
}
|
||||
|
||||
private async createCard(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
return this.apiPost('/cards', params);
|
||||
}
|
||||
|
||||
private async updateCard(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { cardId, ...updates } = params;
|
||||
return this.apiPut(`/cards/${encodeURIComponent(String(cardId))}`, updates);
|
||||
}
|
||||
|
||||
private async listLists(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
if (params.filter) queryParams.filter = params.filter;
|
||||
return this.apiGet(`/boards/${encodeURIComponent(String(params.boardId))}/lists`, queryParams, ['boardId']);
|
||||
}
|
||||
|
||||
private async searchCards(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const queryParams: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
modelTypes: 'cards',
|
||||
};
|
||||
if (params.idBoards) queryParams.idBoards = params.idBoards;
|
||||
if (params.cards_limit) queryParams.cards_limit = params.cards_limit;
|
||||
return this.apiGet('/search', queryParams);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user