Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,26 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient, unwrapResource } from '../../common/client';
import { commonProps } from '../../common/props';
import { Highlight } from '../../common/types';
export const getHighlight = createAction({
auth: hedyAuth,
name: 'get-highlight',
displayName: 'Get Highlight',
description: 'Retrieve a specific highlight by ID.',
props: {
highlightId: commonProps.highlightId,
},
async run(context) {
const highlightId = context.propsValue.highlightId as string;
const client = new HedyApiClient(context.auth.secret_text);
const response = await client.request<Highlight>({
method: HttpMethod.GET,
path: `/highlights/${highlightId}`,
});
return unwrapResource(response);
},
});

View File

@@ -0,0 +1,2 @@
export * from './get-highlight';
export * from './list-highlights';

View File

@@ -0,0 +1,42 @@
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient } from '../../common/client';
import { commonProps } from '../../common/props';
import { topicDropdown } from '../../common/load-options';
import { Highlight } from '../../common/types';
import { assertLimit } from '../../common/validation';
export const listHighlights = createAction({
auth: hedyAuth,
name: 'list-highlights',
displayName: 'List Highlights',
description: 'Retrieve highlights with optional topic filtering and pagination.',
props: {
returnAll: commonProps.returnAll,
limit: commonProps.limit,
format: commonProps.format,
topicId: topicDropdown,
after: commonProps.afterCursor,
before: commonProps.beforeCursor,
},
async run(context) {
const client = new HedyApiClient(context.auth.secret_text);
const { returnAll, limit, format, topicId, after, before } = context.propsValue as {
returnAll?: boolean;
limit?: number;
format?: 'standard' | 'zapier';
topicId?: string;
after?: string;
before?: string;
};
return client.paginate<Highlight>('/highlights', {
returnAll: Boolean(returnAll),
limit: assertLimit(limit),
format,
topicId,
after,
before,
});
},
});

View File

@@ -0,0 +1,4 @@
export * from './sessions';
export * from './highlights';
export * from './todos';
export * from './topics';

View File

@@ -0,0 +1,26 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient, unwrapResource } from '../../common/client';
import { commonProps } from '../../common/props';
import { Session } from '../../common/types';
export const getSession = createAction({
auth: hedyAuth,
name: 'get-session',
displayName: 'Get Session',
description: 'Retrieve a specific session by ID.',
props: {
sessionId: commonProps.sessionId,
},
async run(context) {
const sessionId = context.propsValue.sessionId as string;
const client = new HedyApiClient(context.auth.secret_text);
const response = await client.request<Session>({
method: HttpMethod.GET,
path: `/sessions/${sessionId}`,
});
return unwrapResource(response);
},
});

View File

@@ -0,0 +1,2 @@
export * from './get-session';
export * from './list-sessions';

View File

@@ -0,0 +1,42 @@
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient } from '../../common/client';
import { commonProps } from '../../common/props';
import { topicDropdown } from '../../common/load-options';
import { Session } from '../../common/types';
import { assertLimit } from '../../common/validation';
export const listSessions = createAction({
auth: hedyAuth,
name: 'list-sessions',
displayName: 'List Sessions',
description: 'Retrieve multiple sessions with optional topic filtering and pagination.',
props: {
returnAll: commonProps.returnAll,
limit: commonProps.limit,
format: commonProps.format,
topicId: topicDropdown,
after: commonProps.afterCursor,
before: commonProps.beforeCursor,
},
async run(context) {
const client = new HedyApiClient(context.auth.secret_text);
const { returnAll, limit, format, topicId, after, before } = context.propsValue as {
returnAll?: boolean;
limit?: number;
format?: 'standard' | 'zapier';
topicId?: string;
after?: string;
before?: string;
};
return client.paginate<Session>('/sessions', {
returnAll: Boolean(returnAll),
limit: assertLimit(limit),
format,
topicId,
after,
before,
});
},
});

View File

@@ -0,0 +1,2 @@
export * from './list-todos';
export * from './list-session-todos';

View File

@@ -0,0 +1,56 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient } from '../../common/client';
import { commonProps } from '../../common/props';
import { PaginatedResponse, Todo } from '../../common/types';
import { assertLimit } from '../../common/validation';
function toTodoArray(result: unknown): Todo[] {
if (Array.isArray(result)) {
return result as Todo[];
}
if (result && typeof result === 'object' && 'data' in result) {
const data = (result as PaginatedResponse<Todo>).data;
if (Array.isArray(data)) {
return data;
}
}
return [];
}
export const listSessionTodos = createAction({
auth: hedyAuth,
name: 'list-session-todos',
displayName: 'List Session Todos',
description: 'Retrieve todos generated for a specific session.',
props: {
sessionId: commonProps.sessionId,
returnAll: commonProps.returnAll,
limit: commonProps.limit,
},
async run(context) {
const sessionId = context.propsValue.sessionId as string;
const client = new HedyApiClient(context.auth.secret_text);
const { returnAll, limit } = context.propsValue as {
returnAll?: boolean;
limit?: number;
};
const response = await client.request<Todo[]>({
method: HttpMethod.GET,
path: `/sessions/${sessionId}/todos`,
});
const todos = toTodoArray(response);
if (!returnAll) {
const limited = assertLimit(limit);
return limited ? todos.slice(0, limited) : todos.slice(0, 50);
}
return todos;
},
});

View File

@@ -0,0 +1,54 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient } from '../../common/client';
import { commonProps } from '../../common/props';
import { PaginatedResponse, Todo } from '../../common/types';
import { assertLimit } from '../../common/validation';
function extractTodos(result: unknown): Todo[] {
if (Array.isArray(result)) {
return result as Todo[];
}
if (result && typeof result === 'object' && 'data' in result) {
const data = (result as PaginatedResponse<Todo>).data;
if (Array.isArray(data)) {
return data;
}
}
return [];
}
export const listTodos = createAction({
auth: hedyAuth,
name: 'list-todos',
displayName: 'List Todos',
description: 'Retrieve todos assigned to you in Hedy.',
props: {
returnAll: commonProps.returnAll,
limit: commonProps.limit,
},
async run(context) {
const client = new HedyApiClient(context.auth.secret_text);
const { returnAll, limit } = context.propsValue as {
returnAll?: boolean;
limit?: number;
};
const response = await client.request<Todo[]>({
method: HttpMethod.GET,
path: '/todos',
});
const todos = extractTodos(response);
if (!returnAll) {
const limited = assertLimit(limit);
return limited ? todos.slice(0, limited) : todos.slice(0, 50);
}
return todos;
},
});

View File

@@ -0,0 +1,26 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient, unwrapResource } from '../../common/client';
import { commonProps } from '../../common/props';
import { Topic } from '../../common/types';
export const getTopic = createAction({
auth: hedyAuth,
name: 'get-topic',
displayName: 'Get Topic',
description: 'Retrieve details for a specific topic.',
props: {
topicId: commonProps.topicId,
},
async run(context) {
const topicId = context.propsValue.topicId as string;
const client = new HedyApiClient(context.auth.secret_text);
const response = await client.request<Topic>({
method: HttpMethod.GET,
path: `/topics/${topicId}`,
});
return unwrapResource(response);
},
});

View File

@@ -0,0 +1,3 @@
export * from './get-topic';
export * from './list-topics';
export * from './list-topic-sessions';

View File

@@ -0,0 +1,56 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient } from '../../common/client';
import { commonProps } from '../../common/props';
import { PaginatedResponse, Session } from '../../common/types';
import { assertLimit } from '../../common/validation';
function toSessionArray(result: unknown): Session[] {
if (Array.isArray(result)) {
return result as Session[];
}
if (result && typeof result === 'object' && 'data' in result) {
const data = (result as PaginatedResponse<Session>).data;
if (Array.isArray(data)) {
return data;
}
}
return [];
}
export const listTopicSessions = createAction({
auth: hedyAuth,
name: 'list-topic-sessions',
displayName: 'List Topic Sessions',
description: 'Retrieve sessions associated with a specific topic.',
props: {
topicId: commonProps.topicId,
returnAll: commonProps.returnAll,
limit: commonProps.limit,
},
async run(context) {
const topicId = context.propsValue.topicId as string;
const client = new HedyApiClient(context.auth.secret_text);
const { returnAll, limit } = context.propsValue as {
returnAll?: boolean;
limit?: number;
};
const response = await client.request<Session[]>({
method: HttpMethod.GET,
path: `/topics/${topicId}/sessions`,
});
const sessions = toSessionArray(response);
if (!returnAll) {
const limited = assertLimit(limit);
return limited ? sessions.slice(0, limited) : sessions.slice(0, 50);
}
return sessions;
},
});

View File

@@ -0,0 +1,54 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { hedyAuth } from '../../auth';
import { HedyApiClient } from '../../common/client';
import { commonProps } from '../../common/props';
import { PaginatedResponse, Topic } from '../../common/types';
import { assertLimit } from '../../common/validation';
function toTopicArray(result: unknown): Topic[] {
if (Array.isArray(result)) {
return result as Topic[];
}
if (result && typeof result === 'object' && 'data' in result) {
const data = (result as PaginatedResponse<Topic>).data;
if (Array.isArray(data)) {
return data;
}
}
return [];
}
export const listTopics = createAction({
auth: hedyAuth,
name: 'list-topics',
displayName: 'List Topics',
description: 'Retrieve all topics from your Hedy workspace.',
props: {
returnAll: commonProps.returnAll,
limit: commonProps.limit,
},
async run(context) {
const client = new HedyApiClient(context.auth.secret_text);
const { returnAll, limit } = context.propsValue as {
returnAll?: boolean;
limit?: number;
};
const response = await client.request<Topic[]>({
method: HttpMethod.GET,
path: '/topics',
});
const topics = toTopicArray(response);
if (!returnAll) {
const limited = assertLimit(limit);
return limited ? topics.slice(0, limited) : topics.slice(0, 50);
}
return topics;
},
});

View File

@@ -0,0 +1,41 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { PieceAuth } from '@activepieces/pieces-framework';
import { HedyApiClient } from '../common/client';
export const hedyAuth = PieceAuth.SecretText({
displayName: 'API Key',
description:
'Generate an API key from your Hedy dashboard under Settings → API, then paste the key here (it begins with `hedy_live_`).',
required: true,
validate: async ({ auth }) => {
if (!auth || typeof auth !== 'string') {
return {
valid: false,
error: 'Please provide a valid API key.',
};
}
const client = new HedyApiClient(auth);
try {
await client.request({
method: HttpMethod.GET,
path: '/sessions',
queryParams: {
limit: 1,
},
});
return {
valid: true,
};
} catch (error) {
return {
valid: false,
error:
error instanceof Error
? error.message
: 'Invalid API key. Please verify the key in your Hedy dashboard and try again.',
};
}
},
});

View File

@@ -0,0 +1,203 @@
import {
AuthenticationType,
httpClient,
HttpMethod,
HttpRequest,
QueryParams,
} from '@activepieces/pieces-common';
import { HedyApiError } from './errors';
import {
ApiErrorPayload,
HedyResponse,
PaginatedResponse,
PaginationInfo,
} from './types';
const BASE_URL = 'https://api.hedy.bot';
const DEFAULT_LIMIT = 50;
const MAX_RESULTS = 1000;
const MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 500;
export interface PaginationOptions {
returnAll?: boolean;
limit?: number;
after?: string;
before?: string;
topicId?: string;
format?: 'standard' | 'zapier';
}
interface RequestOptions {
method: HttpMethod;
path: string;
body?: unknown;
queryParams?: Record<string, unknown>;
}
export class HedyApiClient {
constructor(private readonly apiKey: string) {}
async request<T>(options: RequestOptions): Promise<HedyResponse<T>> {
return this.withRetry(() => this.performRequest<T>(options));
}
async paginate<T>(path: string, options: PaginationOptions = {}): Promise<T[]> {
const { returnAll = false, limit = DEFAULT_LIMIT, ...rest } = options;
const collected: T[] = [];
let cursor = rest.after;
let hasMore = true;
while (hasMore) {
const query = {
...rest,
limit: returnAll ? Math.min(limit, 100) : limit,
after: cursor,
};
const response = await this.request<T>({
method: HttpMethod.GET,
path,
queryParams: query,
});
const { data, pagination } = normalizeListResult(response);
collected.push(...data);
if (!returnAll && collected.length >= limit) {
// When NOT returning all, stop when we hit the limit
hasMore = false;
} else if (pagination?.hasMore && pagination.next) {
// Continue if there's more data available
cursor = pagination.next;
hasMore = true;
} else {
// No more data available
hasMore = false;
}
if (collected.length >= MAX_RESULTS) {
hasMore = false;
}
}
return returnAll ? collected : collected.slice(0, limit);
}
private async performRequest<T>({ method, path, body, queryParams }: RequestOptions): Promise<HedyResponse<T>> {
const qs: QueryParams = {};
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value === undefined || value === null || value === '') {
continue;
}
qs[key] = String(value);
}
}
const request: HttpRequest = {
method,
url: `${BASE_URL}${path}`,
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: this.apiKey,
},
headers: {
'User-Agent': 'activepieces-hedy/1.0.0',
'Content-Type': 'application/json',
},
body,
queryParams: qs,
};
try {
const response = await httpClient.sendRequest<HedyResponse<T>>(request);
const { body: responseBody } = response;
if (isErrorPayload(responseBody)) {
throw HedyApiError.fromPayload(responseBody, undefined, response.status);
}
return responseBody;
} catch (error: any) {
const apiErrorPayload: ApiErrorPayload | undefined = error?.response?.body;
const statusCode: number | undefined = error?.response?.status;
throw HedyApiError.fromPayload(apiErrorPayload, error, statusCode);
}
}
private async withRetry<T>(operation: () => Promise<T>): Promise<T> {
let attempt = 0;
let backoff = INITIAL_BACKOFF_MS;
while (attempt < MAX_RETRIES) {
try {
return await operation();
} catch (error) {
const isLastAttempt = attempt === MAX_RETRIES - 1;
if (!(error instanceof HedyApiError) || !this.shouldRetry(error) || isLastAttempt) {
throw error;
}
await this.delay(backoff);
backoff *= 2;
attempt += 1;
}
}
// The loop above either returns or throws, but TypeScript expects a return.
throw new HedyApiError('unknown_error', 'Request failed after multiple retries.');
}
private shouldRetry(error: HedyApiError): boolean {
return error.code === 'rate_limit_exceeded';
}
private async delay(duration: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, duration));
}
}
function isErrorPayload<T>(body: HedyResponse<T>): body is ApiErrorPayload {
return Boolean(body && typeof body === 'object' && 'error' in body);
}
function normalizeListResult<T>(result: HedyResponse<T>): {
data: T[];
pagination?: PaginationInfo;
} {
if (Array.isArray(result)) {
return { data: result };
}
if (result && typeof result === 'object') {
const maybePaginated = result as PaginatedResponse<T>;
if (Array.isArray(maybePaginated.data)) {
return {
data: maybePaginated.data,
pagination: maybePaginated.pagination,
};
}
const maybeData = (result as { data?: unknown }).data;
if (Array.isArray(maybeData)) {
return { data: maybeData as T[] };
}
}
return {
data: result ? [result as T] : [],
};
}
export function unwrapResource<T>(result: HedyResponse<T>): T {
if (result && typeof result === 'object') {
const data = (result as { data?: unknown }).data;
if (data && !Array.isArray(data)) {
return data as T;
}
}
return result as T;
}

View File

@@ -0,0 +1,78 @@
import { ApiErrorPayload } from './types';
const DEFAULT_ERROR_MESSAGE = 'An unknown error occurred while communicating with Hedy.';
function deriveCode(initialCode: string | undefined, statusCode?: number): string {
if (initialCode) {
return initialCode;
}
if (statusCode === 429) {
return 'rate_limit_exceeded';
}
return 'unknown_error';
}
const FRIENDLY_MESSAGES: Record<string, string> = {
webhook_limit_exceeded:
'Maximum webhook limit (10) reached. Please delete unused webhooks in your Hedy dashboard.',
authentication_failed:
'Invalid API key. Please check your Hedy dashboard for the correct API key.',
invalid_event:
'Invalid event type. Valid events: session.created, session.ended, highlight.created, todo.exported.',
invalid_webhook_url:
'Webhook URL must be publicly accessible. For local testing, use a tunneling service like ngrok.',
invalid_parameter:
'Invalid request parameter. Please review your configuration and try again.',
rate_limit_exceeded:
'Rate limit reached. Please wait a moment before trying again.',
unknown_error: DEFAULT_ERROR_MESSAGE,
};
export class HedyApiError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message || DEFAULT_ERROR_MESSAGE);
this.name = 'HedyApiError';
}
static fromPayload(
payload: ApiErrorPayload | undefined,
fallback?: unknown,
statusCode?: number,
): HedyApiError {
if (payload && payload.error) {
const { code, message } = payload.error;
const resolvedCode = deriveCode(code, statusCode);
return new HedyApiError(
resolvedCode,
FRIENDLY_MESSAGES[resolvedCode] ?? message ?? DEFAULT_ERROR_MESSAGE,
payload,
);
}
if (fallback instanceof HedyApiError) {
return fallback;
}
if (fallback instanceof Error) {
const derivedCode = deriveCode(undefined, statusCode);
return new HedyApiError(
derivedCode,
FRIENDLY_MESSAGES[derivedCode] ?? fallback.message ?? DEFAULT_ERROR_MESSAGE,
fallback,
);
}
const finalCode = deriveCode(undefined, statusCode);
return new HedyApiError(
finalCode,
FRIENDLY_MESSAGES[finalCode] ?? DEFAULT_ERROR_MESSAGE,
fallback,
);
}
}

View File

@@ -0,0 +1,70 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { Property } from '@activepieces/pieces-framework';
import { HedyApiClient } from './client';
import { PaginatedResponse, Topic } from './types';
import { hedyAuth } from '../auth';
function toTopicArray(result: unknown): Topic[] {
if (Array.isArray(result)) {
return result as Topic[];
}
if (result && typeof result === 'object' && 'data' in result) {
const data = (result as PaginatedResponse<Topic>).data;
if (Array.isArray(data)) {
return data;
}
}
return [];
}
export const topicDropdown = Property.Dropdown({
auth: hedyAuth,
displayName: 'Topic',
description: 'Optionally filter results by a specific topic.',
required: false,
refreshers: [],
options: async ({ auth }) => {
if (!auth) {
return {
disabled: true,
options: [],
placeholder: 'Connect your Hedy account first.',
};
}
const client = new HedyApiClient(auth.secret_text);
try {
const response = await client.request<Topic[]>({
method: HttpMethod.GET,
path: '/topics',
});
const topics = toTopicArray(response);
if (topics.length === 0) {
return {
disabled: false,
options: [],
placeholder: 'No topics found in your Hedy workspace.',
};
}
return {
disabled: false,
options: topics.map((topic) => ({
label: topic.name,
value: topic.id,
})),
};
} catch (error) {
return {
disabled: true,
options: [],
placeholder:
error instanceof Error ? error.message : 'Failed to load topics. Check your connection.',
};
}
},
});

View File

@@ -0,0 +1,60 @@
import { Property } from '@activepieces/pieces-framework';
export const commonProps = {
sessionId: Property.ShortText({
displayName: 'Session ID',
description: 'The session ID as shown in the Hedy dashboard.',
required: true,
}),
highlightId: Property.ShortText({
displayName: 'Highlight ID',
description: 'The highlight ID as shown in the Hedy dashboard.',
required: true,
}),
topicId: Property.ShortText({
displayName: 'Topic ID',
description: 'The topic ID as shown in the Hedy dashboard.',
required: true,
}),
returnAll: Property.Checkbox({
displayName: 'Return All',
description: 'Return all results instead of using the limit.',
required: false,
defaultValue: false,
}),
limit: Property.Number({
displayName: 'Limit',
description: 'Maximum number of results to return (default 50).',
required: false,
defaultValue: 50,
}),
format: Property.StaticDropdown({
displayName: 'Response Format',
description: 'Select the response format to use.',
required: false,
defaultValue: 'standard',
options: {
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Zapier Compatible', value: 'zapier' },
],
},
}),
afterCursor: Property.ShortText({
displayName: 'After Cursor',
description: 'Pagination cursor used to fetch results after a specific item.',
required: false,
}),
beforeCursor: Property.ShortText({
displayName: 'Before Cursor',
description: 'Pagination cursor used to fetch results before a specific item.',
required: false,
}),
};

View File

@@ -0,0 +1,103 @@
export interface Topic {
id: string;
name: string;
color?: string;
iconName?: string;
}
export interface Todo {
id: string;
text: string;
dueDate?: string;
completed: boolean;
topic?: Topic;
}
export interface Conversation {
question: string;
answer: string;
timestamp?: string;
}
export interface Session {
id: string;
title: string;
startTime: string;
endTime?: string;
duration?: number;
transcript?: string;
conversations?: Conversation[] | string;
meeting_minutes?: string;
meetingMinutes?: string;
recap?: string;
user_todos?: Todo[];
userTodos?: Todo[];
topic?: Topic;
}
export interface Highlight {
id: string;
sessionId: string;
timestamp?: string;
title?: string;
rawQuote?: string;
cleanedQuote?: string;
mainIdea?: string;
aiInsights?: string;
}
export interface TodoExportedPayload {
id: string;
sessionId: string;
text: string;
dueDate?: string;
}
export interface PaginationInfo {
hasMore: boolean;
next?: string;
previous?: string;
}
export interface PaginatedResponse<T> {
success?: boolean;
data: T[];
pagination?: PaginationInfo;
}
export interface ApiSuccessResponse<T> {
success: true;
data: T;
}
export interface ApiErrorPayload {
success?: false;
error: {
code: string;
message: string;
};
}
export type HedyResponse<T> =
| T
| T[]
| PaginatedResponse<T>
| ApiSuccessResponse<T>
| ApiErrorPayload;
export interface WebhookRegistration {
id?: string;
url: string;
events: string[];
signingSecret?: string;
createdAt?: string;
updatedAt?: string;
enabled?: boolean;
}
export enum HedyWebhookEvent {
SessionCreated = 'session.created',
SessionEnded = 'session.ended',
HighlightCreated = 'highlight.created',
TodoExported = 'todo.exported',
}

View File

@@ -0,0 +1,15 @@
export function assertLimit(limit?: number): number | undefined {
if (limit === undefined || limit === null) {
return limit ?? undefined;
}
if (Number.isNaN(limit)) {
throw new Error('Limit must be a number between 1 and 100.');
}
if (limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100.');
}
return limit;
}

View File

@@ -0,0 +1 @@
export * from './webhook';

View File

@@ -0,0 +1,157 @@
import { HttpMethod } from '@activepieces/pieces-common';
import { Property, TriggerStrategy, createTrigger } from '@activepieces/pieces-framework';
import { createHmac, timingSafeEqual } from 'crypto';
import { hedyAuth } from '../../auth';
import { HedyApiClient, unwrapResource } from '../../common/client';
import { HedyWebhookEvent, WebhookRegistration } from '../../common/types';
interface TriggerConfig {
event: HedyWebhookEvent;
name: string;
displayName: string;
description: string;
sampleData?: unknown;
}
export function createHedyWebhookTrigger(config: TriggerConfig) {
return createTrigger({
auth: hedyAuth,
name: config.name,
displayName: config.displayName,
description: config.description,
type: TriggerStrategy.WEBHOOK,
props: {
verifySignature: Property.Checkbox({
displayName: 'Verify Signature',
description:
'Verify the webhook signature using the secret returned by Hedy. Disable this option if Hedy does not provide a signing secret for your account.',
required: false,
defaultValue: false,
}),
},
sampleData: config.sampleData,
async onEnable(context) {
const client = new HedyApiClient(context.auth.secret_text);
const webhookUrl = context.webhookUrl;
if (!webhookUrl) {
throw new Error('Webhook URL is unavailable. Please try again.');
}
const response = await client.request<WebhookRegistration>({
method: HttpMethod.POST,
path: '/webhooks',
body: {
url: webhookUrl,
events: [config.event],
},
});
const webhook = unwrapResource<WebhookRegistration>(response);
if (!webhook?.id) {
throw new Error('Failed to register webhook with Hedy. No webhook ID was returned.');
}
await context.store.put('webhookId', webhook.id);
if (webhook.signingSecret) {
await context.store.put('signingSecret', webhook.signingSecret);
} else {
await context.store.delete('signingSecret');
}
},
async onDisable(context) {
const webhookId = (await context.store.get<string>('webhookId')) ?? undefined;
if (!webhookId) {
return;
}
const client = new HedyApiClient(context.auth.secret_text);
try {
await client.request({
method: HttpMethod.DELETE,
path: `/webhooks/${webhookId}`,
});
} catch (error) {
// Ignore deletion errors webhook may already be removed.
} finally {
await context.store.delete('webhookId');
await context.store.delete('signingSecret');
}
},
async run(context) {
const props = context.propsValue as Record<string, unknown>;
const verifySignatureEnabled = Boolean(props['verifySignature']);
const payload = (context.payload.body ?? {}) as Record<string, unknown>;
if (verifySignatureEnabled) {
const signatureHeader = getSignatureHeader(context.payload.headers ?? {});
if (!signatureHeader) {
throw new Error('Hedy signature header is missing from the webhook request.');
}
const signingSecret = await context.store.get<string>('signingSecret');
if (!signingSecret) {
throw new Error(
'Hedy did not return a signing secret during webhook registration. Disable signature verification or re-register your webhook.',
);
}
const rawBody = extractRawBody(context.payload.rawBody as RawBody, payload);
const expectedSignature = createHmac('sha256', signingSecret).update(rawBody).digest('hex');
if (!secureCompare(expectedSignature, signatureHeader)) {
throw new Error('Webhook signature verification failed. This request may not be from Hedy.');
}
}
const eventType = payload['event'] as string | undefined;
if (eventType && eventType !== config.event) {
return [];
}
return [payload];
},
});
}
type HeadersMap = Record<string, string | string[] | undefined>;
type RawBody = string | Buffer | undefined;
function getSignatureHeader(headers: HeadersMap): string | undefined {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (typeof value === 'string') {
normalized[key.toLowerCase()] = value;
} else if (Array.isArray(value) && value.length > 0) {
normalized[key.toLowerCase()] = value[0];
}
}
return normalized['x-hedy-signature'];
}
function extractRawBody(rawBody: RawBody, payload: Record<string, unknown>): Buffer {
if (typeof rawBody === 'string') {
return Buffer.from(rawBody, 'utf8');
}
if (rawBody instanceof Buffer) {
return rawBody;
}
return Buffer.from(JSON.stringify(payload ?? {}), 'utf8');
}
function secureCompare(expected: string, candidate: string): boolean {
const expectedBuffer = Buffer.from(expected, 'hex');
const candidateBuffer = Buffer.from(candidate, 'hex');
if (expectedBuffer.length !== candidateBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, candidateBuffer);
}

View File

@@ -0,0 +1,9 @@
import { HedyWebhookEvent } from '../../common/types';
import { createHedyWebhookTrigger } from './factory';
export const highlightCreated = createHedyWebhookTrigger({
event: HedyWebhookEvent.HighlightCreated,
name: 'highlight-created',
displayName: 'Highlight Created',
description: 'Triggers when a highlight is created during a session.',
});

View File

@@ -0,0 +1,4 @@
export * from './session-created';
export * from './session-ended';
export * from './highlight-created';
export * from './todo-exported';

View File

@@ -0,0 +1,9 @@
import { HedyWebhookEvent } from '../../common/types';
import { createHedyWebhookTrigger } from './factory';
export const sessionCreated = createHedyWebhookTrigger({
event: HedyWebhookEvent.SessionCreated,
name: 'session-created',
displayName: 'Session Created',
description: 'Triggers when a new session is created in Hedy.',
});

View File

@@ -0,0 +1,9 @@
import { HedyWebhookEvent } from '../../common/types';
import { createHedyWebhookTrigger } from './factory';
export const sessionEnded = createHedyWebhookTrigger({
event: HedyWebhookEvent.SessionEnded,
name: 'session-ended',
displayName: 'Session Ended',
description: 'Triggers when a session is completed in Hedy.',
});

View File

@@ -0,0 +1,9 @@
import { HedyWebhookEvent } from '../../common/types';
import { createHedyWebhookTrigger } from './factory';
export const todoExported = createHedyWebhookTrigger({
event: HedyWebhookEvent.TodoExported,
name: 'todo-exported',
displayName: 'Todo Exported',
description: 'Triggers when a todo item is exported from Hedy.',
});