moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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,
});
}
}

View 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']);
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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';

View 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) };
}
}
}

View 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 } } }`);
}
}

View 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 } } } }`,
);
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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 pages 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 users 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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) };
}
}
}

View 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);
}
}