From 47bb91b64d1708ce775ae3a73e9e9bbc6cfe8729 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:20:50 +0530 Subject: [PATCH 1/7] feat(pieces): add AI agent atomics for apitable and claude (#15703) --- bun.lock | 5 +- .../pieces/community/apitable/package.json | 3 +- .../pieces/community/apitable/src/index.ts | 18 +- .../apitable/src/lib/actions/create-record.ts | 2 + .../apitable/src/lib/actions/delete-record.ts | 41 +++ .../apitable/src/lib/actions/find-record.ts | 2 + .../src/lib/actions/get-datasheet-fields.ts | 35 +++ .../src/lib/actions/get-node-details.ts | 40 +++ .../src/lib/actions/list-datasheets.ts | 34 +++ .../apitable/src/lib/actions/list-spaces.ts | 30 ++ .../apitable/src/lib/actions/list-views.ts | 35 +++ .../apitable/src/lib/actions/search-nodes.ts | 58 ++++ .../apitable/src/lib/actions/update-record.ts | 2 + .../src/lib/actions/upload-attachment.ts | 45 +++ .../apitable/src/lib/common/client.ts | 153 ++++++++-- .../apitable/src/lib/output-schemas.ts | 261 ++++++++++++++++++ .../apitable/src/lib/triggers/new-record.ts | 2 + packages/pieces/community/claude/package.json | 2 +- packages/pieces/community/claude/src/index.ts | 22 +- .../src/lib/actions/cancel-message-batch.ts | 29 ++ .../claude/src/lib/actions/count-tokens.ts | 39 +++ .../src/lib/actions/create-message-batch.ts | 44 +++ .../src/lib/actions/delete-message-batch.ts | 29 ++ .../lib/actions/get-message-batch-results.ts | 49 ++++ .../src/lib/actions/get-message-batch.ts | 29 ++ .../claude/src/lib/actions/get-model.ts | 30 ++ .../src/lib/actions/list-message-batches.ts | 50 ++++ .../claude/src/lib/actions/list-models.ts | 28 ++ .../claude/src/lib/output-schemas.ts | 149 ++++++++++ 29 files changed, 1239 insertions(+), 27 deletions(-) create mode 100644 packages/pieces/community/apitable/src/lib/actions/delete-record.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/get-datasheet-fields.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/get-node-details.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/list-datasheets.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/list-spaces.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/list-views.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/search-nodes.ts create mode 100644 packages/pieces/community/apitable/src/lib/actions/upload-attachment.ts create mode 100644 packages/pieces/community/apitable/src/lib/output-schemas.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/cancel-message-batch.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/count-tokens.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/create-message-batch.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/delete-message-batch.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/get-message-batch-results.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/get-message-batch.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/get-model.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/list-message-batches.ts create mode 100644 packages/pieces/community/claude/src/lib/actions/list-models.ts diff --git a/bun.lock b/bun.lock index 2051d39bfcc5..6186e2d41387 100644 --- a/bun.lock +++ b/bun.lock @@ -697,13 +697,14 @@ }, "packages/pieces/community/apitable": { "name": "@activepieces/piece-apitable", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "dayjs": "1.11.9", + "form-data": "4.0.6", }, "devDependencies": { "tslib": "2.6.2", @@ -1862,7 +1863,7 @@ }, "packages/pieces/community/claude": { "name": "@activepieces/piece-claude", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/apitable/package.json b/packages/pieces/community/apitable/package.json index 95ae7eb77682..30b352842c91 100644 --- a/packages/pieces/community/apitable/package.json +++ b/packages/pieces/community/apitable/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-apitable", - "version": "0.2.0", + "version": "0.3.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { @@ -12,6 +12,7 @@ "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "dayjs": "1.11.9", + "form-data": "4.0.6", "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*" }, diff --git a/packages/pieces/community/apitable/src/index.ts b/packages/pieces/community/apitable/src/index.ts index 92fc9d8d5882..2faa0d9c74d6 100644 --- a/packages/pieces/community/apitable/src/index.ts +++ b/packages/pieces/community/apitable/src/index.ts @@ -9,6 +9,14 @@ import { PieceCategory } from '@activepieces/pieces-framework'; import { createRecordAction } from './lib/actions/create-record'; import { findRecordAction } from './lib/actions/find-record'; import { updateRecordAction } from './lib/actions/update-record'; +import { deleteRecordAction } from './lib/actions/delete-record'; +import { listSpacesAction } from './lib/actions/list-spaces'; +import { listDatasheetsAction } from './lib/actions/list-datasheets'; +import { getDatasheetFieldsAction } from './lib/actions/get-datasheet-fields'; +import { listViewsAction } from './lib/actions/list-views'; +import { uploadAttachmentAction } from './lib/actions/upload-attachment'; +import { searchNodesAction } from './lib/actions/search-nodes'; +import { getNodeDetailsAction } from './lib/actions/get-node-details'; import { newRecordTrigger } from './lib/triggers/new-record'; import { makeClient } from './lib/common'; import { APITableAuth } from './lib/auth'; @@ -17,7 +25,7 @@ export const apitable = createPiece({ displayName: 'AITable', auth: APITableAuth, description: `Interactive spreadsheets with collaboration`, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/apitable.png', categories: [PieceCategory.PRODUCTIVITY], authors: [ @@ -31,6 +39,14 @@ export const apitable = createPiece({ createRecordAction, updateRecordAction, findRecordAction, + deleteRecordAction, + listSpacesAction, + listDatasheetsAction, + getDatasheetFieldsAction, + listViewsAction, + uploadAttachmentAction, + searchNodesAction, + getNodeDetailsAction, createCustomApiCallAction({ baseUrl: (auth) => { return (auth?.props.apiTableUrl ?? ''); diff --git a/packages/pieces/community/apitable/src/lib/actions/create-record.ts b/packages/pieces/community/apitable/src/lib/actions/create-record.ts index 2a4bf1b3e462..944c5fd9740d 100644 --- a/packages/pieces/community/apitable/src/lib/actions/create-record.ts +++ b/packages/pieces/community/apitable/src/lib/actions/create-record.ts @@ -5,6 +5,7 @@ import { } from '@activepieces/pieces-framework'; import { APITableCommon, createNewFields, makeClient } from '../common'; import { APITableAuth } from '../auth'; +import { createRecordActionOutputSchema } from '../output-schemas'; export const createRecordAction = createAction({ auth: APITableAuth, @@ -23,6 +24,7 @@ export const createRecordAction = createAction({ datasheet_id: APITableCommon.datasheet_id, fields: APITableCommon.fields, }, + outputSchema: createRecordActionOutputSchema, async run(context) { const auth = context.auth; const datasheetId = context.propsValue.datasheet_id; diff --git a/packages/pieces/community/apitable/src/lib/actions/delete-record.ts b/packages/pieces/community/apitable/src/lib/actions/delete-record.ts new file mode 100644 index 000000000000..59cd0c88063b --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/delete-record.ts @@ -0,0 +1,41 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { deleteRecordActionOutputSchema } from '../output-schemas'; + +export const deleteRecordAction = createAction({ + auth: APITableAuth, + name: 'apitable_delete_record', + classification: 'DESTRUCTIVE', + displayName: 'Delete Record', + description: 'Deletes one or more records from a datasheet.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one or more records from an AITable datasheet by record ID; obtain the IDs from Find Records first. Use only when records should be removed entirely, not just updated. Not idempotent: retrying after a successful delete errors because the records no longer exist.', + idempotent: false, + }, + props: { + space_id: APITableCommon.space_id, + datasheet_id: APITableCommon.datasheet_id, + recordIds: Property.Array({ + displayName: 'Record IDs', + description: 'The IDs of the records to delete.', + required: true, + }), + }, + outputSchema: deleteRecordActionOutputSchema, + async run(context) { + const datasheetId = context.propsValue.datasheet_id as string; + const recordIds = context.propsValue.recordIds as string[]; + + const client = makeClient(context.auth.props); + const response = await client.deleteRecords(datasheetId, recordIds); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/find-record.ts b/packages/pieces/community/apitable/src/lib/actions/find-record.ts index a9a382ad7ab2..c1b674ce0bfd 100644 --- a/packages/pieces/community/apitable/src/lib/actions/find-record.ts +++ b/packages/pieces/community/apitable/src/lib/actions/find-record.ts @@ -6,6 +6,7 @@ import { import { APITableCommon, makeClient } from '../common'; import { APITableAuth } from '../auth'; import { prepareQuery } from '../common/client'; +import { findRecordActionOutputSchema } from '../output-schemas'; export const findRecordAction = createAction({ auth: APITableAuth, @@ -55,6 +56,7 @@ export const findRecordAction = createAction({ required: false, }), }, + outputSchema: findRecordActionOutputSchema, async run(context) { const datasheetId = context.propsValue.datasheet_id; const recordIds = context.propsValue.recordIds ?? [] diff --git a/packages/pieces/community/apitable/src/lib/actions/get-datasheet-fields.ts b/packages/pieces/community/apitable/src/lib/actions/get-datasheet-fields.ts new file mode 100644 index 000000000000..6b6ff2e13922 --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/get-datasheet-fields.ts @@ -0,0 +1,35 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { getDatasheetFieldsActionOutputSchema } from '../output-schemas'; + +export const getDatasheetFieldsAction = createAction({ + auth: APITableAuth, + name: 'apitable_get_datasheet_fields', + classification: 'READ', + displayName: 'Get Datasheet Fields', + description: 'Reads the field (column) definitions of a datasheet.', + audience: 'ai', + aiMetadata: { + description: + 'Reads the field (column) definitions of an AITable datasheet, including each field\'s name, type, and the option list for select/member fields. Use before creating or updating records to learn the valid field names and choices for that datasheet. Idempotent: it only reads data.', + idempotent: true, + }, + props: { + space_id: APITableCommon.space_id, + datasheet_id: APITableCommon.datasheet_id, + }, + outputSchema: getDatasheetFieldsActionOutputSchema, + async run(context) { + const client = makeClient(context.auth.props); + const response = await client.getDatasheetFields( + context.propsValue.datasheet_id as string + ); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/get-node-details.ts b/packages/pieces/community/apitable/src/lib/actions/get-node-details.ts new file mode 100644 index 000000000000..13ba734ff6ab --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/get-node-details.ts @@ -0,0 +1,40 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { getNodeDetailsActionOutputSchema } from '../output-schemas'; + +export const getNodeDetailsAction = createAction({ + auth: APITableAuth, + name: 'apitable_get_node_details', + classification: 'READ', + displayName: 'Get Node Details', + description: 'Reads the details of a single file node, including its children.', + audience: 'ai', + aiMetadata: { + description: + 'Reads a single AITable node (datasheet, folder, form, dashboard, etc.) by ID, returning its name, type, and, for a folder, the child nodes inside it. Use to confirm a node\'s type or list a folder\'s contents; obtain the node ID from Search Nodes or List Datasheets. Idempotent: it only reads data.', + idempotent: true, + }, + props: { + space_id: APITableCommon.space_id, + node_id: Property.ShortText({ + displayName: 'Node ID', + description: 'The ID of the node to read.', + required: true, + }), + }, + outputSchema: getNodeDetailsActionOutputSchema, + async run(context) { + const client = makeClient(context.auth.props); + const response = await client.getNodeDetails( + context.propsValue.space_id as string, + context.propsValue.node_id + ); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/list-datasheets.ts b/packages/pieces/community/apitable/src/lib/actions/list-datasheets.ts new file mode 100644 index 000000000000..f315ea3c32e2 --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/list-datasheets.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { listDatasheetsActionOutputSchema } from '../output-schemas'; + +export const listDatasheetsAction = createAction({ + auth: APITableAuth, + name: 'apitable_list_datasheets', + classification: 'SEARCH', + displayName: 'List Datasheets', + description: 'Lists the datasheets available in a space.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the datasheets in an AITable space, returning each datasheet\'s ID and name. Use to discover a datasheet ID before creating, finding, updating, or deleting its records. Idempotent: it only reads data.', + idempotent: true, + }, + props: { + space_id: APITableCommon.space_id, + }, + outputSchema: listDatasheetsActionOutputSchema, + async run(context) { + const client = makeClient(context.auth.props); + const response = await client.listDatasheets( + context.propsValue.space_id as string + ); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/list-spaces.ts b/packages/pieces/community/apitable/src/lib/actions/list-spaces.ts new file mode 100644 index 000000000000..4f3674dab4c9 --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/list-spaces.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { listSpacesActionOutputSchema } from '../output-schemas'; + +export const listSpacesAction = createAction({ + auth: APITableAuth, + name: 'apitable_list_spaces', + classification: 'SEARCH', + displayName: 'List Spaces', + description: 'Lists the spaces available to the authenticated account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists every space (workspace) the authenticated AITable account can access, returning each space\'s ID and name. Use to discover a space ID before listing its datasheets. Idempotent: it only reads data.', + idempotent: true, + }, + props: {}, + outputSchema: listSpacesActionOutputSchema, + async run(context) { + const client = makeClient(context.auth.props); + const response = await client.listSpaces(); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/list-views.ts b/packages/pieces/community/apitable/src/lib/actions/list-views.ts new file mode 100644 index 000000000000..25372ba73b77 --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/list-views.ts @@ -0,0 +1,35 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { listViewsActionOutputSchema } from '../output-schemas'; + +export const listViewsAction = createAction({ + auth: APITableAuth, + name: 'apitable_list_views', + classification: 'SEARCH', + displayName: 'List Views', + description: 'Lists the views of a datasheet.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the views (Grid, Kanban, Gallery, etc.) of an AITable datasheet, returning each view\'s ID, name, and type. Use to discover a view ID to scope Find Records to that view\'s rows and sort order. Idempotent: it only reads data.', + idempotent: true, + }, + props: { + space_id: APITableCommon.space_id, + datasheet_id: APITableCommon.datasheet_id, + }, + outputSchema: listViewsActionOutputSchema, + async run(context) { + const client = makeClient(context.auth.props); + const response = await client.listViews( + context.propsValue.datasheet_id as string + ); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/search-nodes.ts b/packages/pieces/community/apitable/src/lib/actions/search-nodes.ts new file mode 100644 index 000000000000..4b0acb94b123 --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/search-nodes.ts @@ -0,0 +1,58 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { searchNodesActionOutputSchema } from '../output-schemas'; + +export const searchNodesAction = createAction({ + auth: APITableAuth, + name: 'apitable_search_nodes', + classification: 'SEARCH', + displayName: 'Search Nodes', + description: + 'Searches the file nodes (datasheets, folders, forms, dashboards, etc.) in a space.', + audience: 'ai', + aiMetadata: { + description: + 'Searches every file node in an AITable space by type and/or name query, regardless of folder hierarchy; returns id, name, type and parent for each match. Use to find folders, forms, dashboards, or mirrors that List Datasheets does not surface since it only returns datasheets. Idempotent: it only reads data.', + idempotent: true, + }, + props: { + space_id: APITableCommon.space_id, + type: Property.StaticDropdown({ + displayName: 'Node Type', + description: 'The type of node to search for.', + required: true, + options: { + options: [ + { label: 'Datasheet', value: 'Datasheet' }, + { label: 'Folder', value: 'Folder' }, + { label: 'Form', value: 'Form' }, + { label: 'Dashboard', value: 'Dashboard' }, + { label: 'Mirror', value: 'Mirror' }, + ], + }, + }), + query: Property.ShortText({ + displayName: 'Name Query', + description: 'Filter results to node names containing this text.', + required: false, + }), + }, + outputSchema: searchNodesActionOutputSchema, + async run(context) { + const client = makeClient(context.auth.props); + const response = await client.searchNodes( + context.propsValue.space_id as string, + { + type: context.propsValue.type, + query: context.propsValue.query, + } + ); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/actions/update-record.ts b/packages/pieces/community/apitable/src/lib/actions/update-record.ts index afe1023849c0..e1dac94cb727 100644 --- a/packages/pieces/community/apitable/src/lib/actions/update-record.ts +++ b/packages/pieces/community/apitable/src/lib/actions/update-record.ts @@ -6,6 +6,7 @@ import { } from '@activepieces/pieces-framework'; import { APITableCommon, createNewFields, makeClient } from '../common'; import { APITableAuth } from '../auth'; +import { updateRecordActionOutputSchema } from '../output-schemas'; export const updateRecordAction = createAction({ auth: APITableAuth, @@ -29,6 +30,7 @@ export const updateRecordAction = createAction({ }), fields: APITableCommon.fields, }, + outputSchema: updateRecordActionOutputSchema, async run(context) { const auth = context.auth; const datasheetId = context.propsValue.datasheet_id; diff --git a/packages/pieces/community/apitable/src/lib/actions/upload-attachment.ts b/packages/pieces/community/apitable/src/lib/actions/upload-attachment.ts new file mode 100644 index 000000000000..9785b8c63066 --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/actions/upload-attachment.ts @@ -0,0 +1,45 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { APITableCommon, makeClient } from '../common'; +import { APITableAuth } from '../auth'; +import { uploadAttachmentActionOutputSchema } from '../output-schemas'; + +export const uploadAttachmentAction = createAction({ + auth: APITableAuth, + name: 'apitable_upload_attachment', + classification: 'WRITE', + displayName: 'Upload Attachment', + description: 'Uploads a file to a datasheet and returns its storage URL.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a single file to an AITable datasheet and returns its stored token and URL. Use to obtain the URL for a file before writing it into an Attachment field with Create Record or Update Record. Not idempotent: each call stores a new file, so repeating it duplicates the upload.', + idempotent: false, + }, + props: { + space_id: APITableCommon.space_id, + datasheet_id: APITableCommon.datasheet_id, + file: Property.File({ + displayName: 'File', + description: 'The file to upload.', + required: true, + }), + }, + outputSchema: uploadAttachmentActionOutputSchema, + async run(context) { + const datasheetId = context.propsValue.datasheet_id as string; + const file = context.propsValue.file; + + const client = makeClient(context.auth.props); + const response = await client.uploadAttachment( + datasheetId, + file.filename, + file.data + ); + + if (!response.success) { + throw new Error(JSON.stringify(response, undefined, 2)); + } + + return response; + }, +}); diff --git a/packages/pieces/community/apitable/src/lib/common/client.ts b/packages/pieces/community/apitable/src/lib/common/client.ts index 8387a1792d3c..a5d15ee4337b 100644 --- a/packages/pieces/community/apitable/src/lib/common/client.ts +++ b/packages/pieces/community/apitable/src/lib/common/client.ts @@ -6,6 +6,7 @@ import { httpClient, } from '@activepieces/pieces-common'; import { AITableFieldType } from './constants'; +import FormData from 'form-data'; function emptyValueFilter( accessor: (key: string) => any @@ -20,6 +21,16 @@ function emptyValueFilter( }; } +type NodeSummary = { + id: string; + name: string; + type: string; + icon: string; + isFav: boolean; + permission: number; + parentId?: string; +}; + export function prepareQuery(request?: Record): QueryParams { const params: QueryParams = {}; if (!request) return params; @@ -38,7 +49,8 @@ export class AITableClient { method: HttpMethod, resourceUri: string, query?: QueryParams, - body: any | undefined = undefined + body: any | undefined = undefined, + headers?: Record ): Promise { const baseUrl = this.apiTableUrl.replace(/\/$/, ''); const res = await httpClient.sendRequest({ @@ -50,45 +62,79 @@ export class AITableClient { }, queryParams: query, body: body, + headers: headers, }); return res.body; } async listSpaces() { return await this.makeRequest<{ + success: boolean; + code: number; + message: string; data: { spaces: { id: string; name: string; + isAdmin: boolean; }[]; }; }>(HttpMethod.GET, '/v1/spaces'); } async listDatasheets(space_id: string) { return await this.makeRequest<{ + success: boolean; + code: number; + message: string; data: { - nodes: { - id: string; - name: string; - }[]; + nodes: NodeSummary[]; }; }>(HttpMethod.GET, `/v2/spaces/${space_id}/nodes`, { type: 'Datasheet' }); } + async searchNodes( + space_id: string, + query?: { type?: string; query?: string } + ) { + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + data: { + nodes: NodeSummary[]; + }; + }>(HttpMethod.GET, `/v2/spaces/${space_id}/nodes`, prepareQuery(query)); + } + + async getNodeDetails(space_id: string, node_id: string) { + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + data: NodeSummary & { + children?: NodeSummary[]; + }; + }>(HttpMethod.GET, `/v1/spaces/${space_id}/nodes/${node_id}`); + } + async getDatasheetFields(datasheet_id: string) { return await this.makeRequest<{ + success: boolean; + code: number; + message: string; data: { fields: { id: string; name: string; type: AITableFieldType; - desc: string; + editable: boolean; + isPrimary?: boolean; property?: { - format?: string; defaultValue?: string; options?: { + id: string; name: string; - id?: string; + color?: { name: string; value: string }; }[]; }; }[]; @@ -97,26 +143,43 @@ export class AITableClient { } async createRecord(datasheet_id: string, request: object) { - return await this.makeRequest( - HttpMethod.POST, - `/v1/datasheets/${datasheet_id}/records`, - undefined, - request - ); + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + data: { + records: { + recordId: string; + fields: Record; + }[]; + }; + }>(HttpMethod.POST, `/v1/datasheets/${datasheet_id}/records`, undefined, request); } async updateRecord(datasheet_id: string, request: object) { - return await this.makeRequest( - HttpMethod.PATCH, - `/v1/datasheets/${datasheet_id}/records`, - undefined, - request - ); + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + data: { + records: { + recordId: string; + createdAt: number; + updatedAt: number; + fields: Record; + }[]; + }; + }>(HttpMethod.PATCH, `/v1/datasheets/${datasheet_id}/records`, undefined, request); } async listRecords(datasheet_id: string, query?: QueryParams) { return await this.makeRequest<{ + success: boolean; + code: number; + message: string; data: { total: number; + pageNum: number; + pageSize: number; records: { recordId: string; createdAt: number; @@ -126,4 +189,54 @@ export class AITableClient { }; }>(HttpMethod.GET, `/v1/datasheets/${datasheet_id}/records`, query); } + + async uploadAttachment(datasheet_id: string, filename: string, file: Buffer) { + const formData = new FormData(); + formData.append('file', file, { filename }); + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + data: { + token: string; + mimeType: string; + size: number; + width?: number; + height?: number; + name: string; + url: string; + }; + }>( + HttpMethod.POST, + `/v1/datasheets/${datasheet_id}/attachments`, + undefined, + formData, + formData.getHeaders() + ); + } + + async listViews(datasheet_id: string) { + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + data: { + views: { + id: string; + name: string; + type: string; + }[]; + }; + }>(HttpMethod.GET, `/v1/datasheets/${datasheet_id}/views`); + } + + async deleteRecords(datasheet_id: string, recordIds: string[]) { + return await this.makeRequest<{ + success: boolean; + code: number; + message: string; + }>(HttpMethod.DELETE, `/v1/datasheets/${datasheet_id}/records`, { + recordIds: recordIds.join(','), + }); + } } diff --git a/packages/pieces/community/apitable/src/lib/output-schemas.ts b/packages/pieces/community/apitable/src/lib/output-schemas.ts new file mode 100644 index 000000000000..5fc67e9308bd --- /dev/null +++ b/packages/pieces/community/apitable/src/lib/output-schemas.ts @@ -0,0 +1,261 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const envelopeFields: OutputSchema['fields'] = [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'code', label: 'Code', format: 'number' }, + { key: 'message', label: 'Message' }, +]; + +const createdRecordFields: OutputSchema['fields'] = [ + { key: 'recordId', label: 'Record ID' }, + { key: 'fields', label: 'Fields', dynamicKey: true }, +]; + +const recordFields: OutputSchema['fields'] = [ + { key: 'recordId', label: 'Record ID' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'fields', label: 'Fields', dynamicKey: true }, +]; + +const datasheetFieldFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Field ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'editable', label: 'Editable', format: 'boolean' }, + { key: 'isPrimary', label: 'Primary Field', format: 'boolean' }, + { + key: 'property', + label: 'Property', + children: [ + { key: 'defaultValue', label: 'Default Value' }, + { + key: 'options', + label: 'Options', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { + key: 'color', + label: 'Color', + children: [ + { key: 'name', label: 'Name' }, + { key: 'value', label: 'Value' }, + ], + }, + ], + }, + ], + }, +]; + +const nodeSummaryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Node ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'icon', label: 'Icon' }, + { key: 'isFav', label: 'Favorite', format: 'boolean' }, + { key: 'permission', label: 'Permission', format: 'number' }, +]; + +export const createRecordActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'records', + label: 'Records', + labelKey: 'recordId', + listItems: createdRecordFields, + }, + ], + }, + ], +}; + +export const updateRecordActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'records', + label: 'Records', + labelKey: 'recordId', + listItems: recordFields, + }, + ], + }, + ], +}; + +export const findRecordActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { key: 'total', label: 'Total', format: 'number' }, + { key: 'pageNum', label: 'Page Number', format: 'number' }, + { key: 'pageSize', label: 'Page Size', format: 'number' }, + { + key: 'records', + label: 'Records', + labelKey: 'recordId', + listItems: recordFields, + }, + ], + }, + ], +}; + +export const listSpacesActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'spaces', + label: 'Spaces', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Space ID' }, + { key: 'name', label: 'Name' }, + { key: 'isAdmin', label: 'Is Admin', format: 'boolean' }, + ], + }, + ], + }, + ], +}; + +export const listDatasheetsActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'nodes', + label: 'Datasheets', + labelKey: 'name', + listItems: [...nodeSummaryFields, { key: 'parentId', label: 'Parent ID' }], + }, + ], + }, + ], +}; + +export const searchNodesActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'nodes', + label: 'Nodes', + labelKey: 'name', + listItems: [...nodeSummaryFields, { key: 'parentId', label: 'Parent ID' }], + }, + ], + }, + ], +}; + +export const getNodeDetailsActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Node', + children: [ + ...nodeSummaryFields, + { + key: 'children', + label: 'Children', + labelKey: 'name', + listItems: nodeSummaryFields, + }, + ], + }, + ], +}; + +export const listViewsActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'views', + label: 'Views', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'View ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + ], + }, + ], + }, + ], +}; + +export const getDatasheetFieldsActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { + key: 'fields', + label: 'Fields', + labelKey: 'name', + listItems: datasheetFieldFields, + }, + ], + }, + ], +}; + +export const uploadAttachmentActionOutputSchema: OutputSchema = { + fields: [ + ...envelopeFields, + { + key: 'data', + label: 'Data', + children: [ + { key: 'token', label: 'Token' }, + { key: 'name', label: 'File Name' }, + { key: 'mimeType', label: 'MIME Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + { key: 'url', label: 'URL', format: 'url' }, + ], + }, + ], +}; + +export const deleteRecordActionOutputSchema: OutputSchema = { + fields: envelopeFields, +}; + +export const newRecordTriggerOutputSchema: OutputSchema = { + fields: recordFields, +}; diff --git a/packages/pieces/community/apitable/src/lib/triggers/new-record.ts b/packages/pieces/community/apitable/src/lib/triggers/new-record.ts index 7c32f353520c..53fe11650563 100644 --- a/packages/pieces/community/apitable/src/lib/triggers/new-record.ts +++ b/packages/pieces/community/apitable/src/lib/triggers/new-record.ts @@ -11,6 +11,7 @@ import { } from '@activepieces/pieces-common'; import { APITableCommon, makeClient } from '../common'; import dayjs from 'dayjs'; +import { newRecordTriggerOutputSchema } from '../output-schemas'; const polling: Polling< AppConnectionValueForAuthProperty, @@ -52,6 +53,7 @@ export const newRecordTrigger = createTrigger({ space_id: APITableCommon.space_id, datasheet_id: APITableCommon.datasheet_id, }, + outputSchema: newRecordTriggerOutputSchema, sampleData: { recordId: 'rec2T5ppW1Mal', createdAt: 1689772153000, diff --git a/packages/pieces/community/claude/package.json b/packages/pieces/community/claude/package.json index 59aecb9dde72..b133e1825ae5 100644 --- a/packages/pieces/community/claude/package.json +++ b/packages/pieces/community/claude/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-claude", - "version": "0.5.0", + "version": "0.6.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/claude/src/index.ts b/packages/pieces/community/claude/src/index.ts index 3703a3762c06..0399909c9b2d 100644 --- a/packages/pieces/community/claude/src/index.ts +++ b/packages/pieces/community/claude/src/index.ts @@ -1,21 +1,39 @@ -import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { askClaude } from './lib/actions/send-prompt'; import { baseUrl } from './lib/common/common'; import { PieceCategory } from '@activepieces/pieces-framework'; import { extractStructuredDataAction } from './lib/actions/extract-structured-data'; +import { listModelsAction } from './lib/actions/list-models'; +import { getModelAction } from './lib/actions/get-model'; +import { createMessageBatchAction } from './lib/actions/create-message-batch'; +import { getMessageBatchAction } from './lib/actions/get-message-batch'; +import { listMessageBatchesAction } from './lib/actions/list-message-batches'; +import { cancelMessageBatchAction } from './lib/actions/cancel-message-batch'; +import { deleteMessageBatchAction } from './lib/actions/delete-message-batch'; +import { getMessageBatchResultsAction } from './lib/actions/get-message-batch-results'; +import { countTokensAction } from './lib/actions/count-tokens'; import { claudeAuth } from './lib/auth'; export const claude = createPiece({ displayName: 'Anthropic Claude', auth: claudeAuth, - minimumSupportedRelease: '0.63.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/claude.png', categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], authors: ['dennisrongo','kishanprmr'], actions: [ askClaude, extractStructuredDataAction, + listModelsAction, + getModelAction, + createMessageBatchAction, + getMessageBatchAction, + listMessageBatchesAction, + cancelMessageBatchAction, + deleteMessageBatchAction, + getMessageBatchResultsAction, + countTokensAction, createCustomApiCallAction({ auth: claudeAuth, baseUrl: () => baseUrl, diff --git a/packages/pieces/community/claude/src/lib/actions/cancel-message-batch.ts b/packages/pieces/community/claude/src/lib/actions/cancel-message-batch.ts new file mode 100644 index 000000000000..0342d4e39eb7 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/cancel-message-batch.ts @@ -0,0 +1,29 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { cancelMessageBatchActionOutputSchema } from '../output-schemas'; + +export const cancelMessageBatchAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'cancel_message_batch', + classification: 'WRITE', + displayName: 'Cancel Message Batch', + description: 'Cancels an in-progress message batch. Already-completed requests in the batch are not undone.', + aiMetadata: { + description: + "Cancels a message batch that is still 'in_progress'. The batch moves to 'canceling' and the system finishes any non-interruptible requests already underway before finalizing; check request_counts.canceled via Get Message Batch to see how many were actually stopped. Idempotent: canceling an already-canceling or ended batch has no further effect.", + idempotent: true, + }, + props: { + messageBatchId: Property.ShortText({ + displayName: 'Message Batch ID', + required: true, + }), + }, + outputSchema: cancelMessageBatchActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + return anthropic.messages.batches.cancel(context.propsValue.messageBatchId); + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/count-tokens.ts b/packages/pieces/community/claude/src/lib/actions/count-tokens.ts new file mode 100644 index 000000000000..ab747ff10112 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/count-tokens.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { modelDropdown } from '../common/common'; +import { countTokensActionOutputSchema } from '../output-schemas'; + +export const countTokensAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'count_tokens', + classification: 'READ', + displayName: 'Count Tokens', + description: 'Counts how many input tokens a prompt would use for a given model, without generating a response.', + aiMetadata: { + description: + 'Counts the input tokens a prompt and optional system prompt would use for a given Claude model, without sending it to be answered. Use to check a prompt fits a model\'s context window or to estimate cost before calling Ask Claude or Extract Structured Data. Idempotent: it only counts, it never generates.', + idempotent: true, + }, + props: { + model: modelDropdown, + systemPrompt: Property.LongText({ + displayName: 'System Prompt', + required: false, + }), + prompt: Property.LongText({ + displayName: 'Prompt', + required: true, + }), + }, + outputSchema: countTokensActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + return anthropic.messages.countTokens({ + model: context.propsValue.model, + system: context.propsValue.systemPrompt, + messages: [{ role: 'user', content: context.propsValue.prompt }], + }); + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/create-message-batch.ts b/packages/pieces/community/claude/src/lib/actions/create-message-batch.ts new file mode 100644 index 000000000000..565db49cc445 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/create-message-batch.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { createMessageBatchActionOutputSchema } from '../output-schemas'; + +export const createMessageBatchAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'create_message_batch', + classification: 'WRITE', + displayName: 'Create Message Batch', + description: 'Submits a batch of Messages API requests to be processed asynchronously, at up to 24 hours turnaround.', + aiMetadata: { + description: + "Submits multiple Messages API requests as a single asynchronous batch (up to 24h to complete), cheaper than calling Ask Claude repeatedly for large volumes. Each request needs a unique custom_id and a params object shaped like the Messages API (model, messages, max_tokens, system). Poll status with Get Message Batch and read output with Get Message Batch Results once processing_status is 'ended'. Not idempotent: each call creates a new batch.", + idempotent: false, + }, + props: { + requests: Property.Json({ + displayName: 'Requests', + description: + 'Array of { custom_id, params } objects. Each custom_id must be unique within the batch; each params object matches the Messages API request body (model, messages, max_tokens, system, etc).', + required: true, + defaultValue: [ + { + custom_id: 'request-1', + params: { + model: 'claude-3-5-haiku-latest', + max_tokens: 1024, + messages: [{ role: 'user', content: 'Hello, Claude!' }], + }, + }, + ], + }), + }, + outputSchema: createMessageBatchActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + return anthropic.messages.batches.create({ + requests: context.propsValue + .requests as unknown as Anthropic.Messages.BatchCreateParams['requests'], + }); + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/delete-message-batch.ts b/packages/pieces/community/claude/src/lib/actions/delete-message-batch.ts new file mode 100644 index 000000000000..b7ff42f5a3e2 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/delete-message-batch.ts @@ -0,0 +1,29 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { deleteMessageBatchActionOutputSchema } from '../output-schemas'; + +export const deleteMessageBatchAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'delete_message_batch', + classification: 'DESTRUCTIVE', + displayName: 'Delete Message Batch', + description: 'Deletes a message batch and its results. The batch must have finished processing first.', + aiMetadata: { + description: + "Permanently deletes a message batch, once its processing_status is 'ended' (an in-progress batch must be canceled first via Cancel Message Batch). This removes access to the batch's results. Not idempotent: deleting an already-deleted batch fails with not_found.", + idempotent: false, + }, + props: { + messageBatchId: Property.ShortText({ + displayName: 'Message Batch ID', + required: true, + }), + }, + outputSchema: deleteMessageBatchActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + return anthropic.messages.batches.delete(context.propsValue.messageBatchId); + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/get-message-batch-results.ts b/packages/pieces/community/claude/src/lib/actions/get-message-batch-results.ts new file mode 100644 index 000000000000..8029df2b3bc4 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/get-message-batch-results.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { getMessageBatchResultsActionOutputSchema } from '../output-schemas'; + +const DEFAULT_MAX_RESULTS = 100; + +export const getMessageBatchResultsAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'get_message_batch_results', + classification: 'READ', + displayName: 'Get Message Batch Results', + description: 'Reads the per-request results of a finished message batch.', + aiMetadata: { + description: + "Reads the results of a message batch once Get Message Batch reports processing_status 'ended', returning each request's custom_id alongside its outcome ('succeeded' with the Message, or 'errored'/'canceled'/'expired'). Results are not guaranteed to be in request order; match on custom_id. Idempotent: it only reads data.", + idempotent: true, + }, + props: { + messageBatchId: Property.ShortText({ + displayName: 'Message Batch ID', + required: true, + }), + maxResults: Property.Number({ + displayName: 'Max Results', + description: `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}).`, + required: false, + }), + }, + outputSchema: getMessageBatchResultsActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + const maxResults = context.propsValue.maxResults ?? DEFAULT_MAX_RESULTS; + if (!Number.isInteger(maxResults) || maxResults <= 0) { + throw new Error('Max Results must be a positive integer.'); + } + const decoder = await anthropic.messages.batches.results(context.propsValue.messageBatchId); + + const results = []; + for await (const result of decoder) { + results.push(result); + if (results.length >= maxResults) { + break; + } + } + return { results }; + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/get-message-batch.ts b/packages/pieces/community/claude/src/lib/actions/get-message-batch.ts new file mode 100644 index 000000000000..1cc172be0334 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/get-message-batch.ts @@ -0,0 +1,29 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { getMessageBatchActionOutputSchema } from '../output-schemas'; + +export const getMessageBatchAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'get_message_batch', + classification: 'READ', + displayName: 'Get Message Batch', + description: 'Reads the status and request counts of a message batch, to poll for completion.', + aiMetadata: { + description: + "Reads a message batch by ID, returning its processing_status ('in_progress', 'canceling', or 'ended') and a tally of succeeded/errored/canceled/expired requests. Poll this after Create Message Batch until processing_status is 'ended', then call Get Message Batch Results. Idempotent: it only reads data.", + idempotent: true, + }, + props: { + messageBatchId: Property.ShortText({ + displayName: 'Message Batch ID', + required: true, + }), + }, + outputSchema: getMessageBatchActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + return anthropic.messages.batches.retrieve(context.propsValue.messageBatchId); + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/get-model.ts b/packages/pieces/community/claude/src/lib/actions/get-model.ts new file mode 100644 index 000000000000..fb94920c5eca --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/get-model.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { getModelActionOutputSchema } from '../output-schemas'; + +export const getModelAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'get_model', + classification: 'READ', + displayName: 'Get Model', + description: 'Reads details for a single Claude model, or resolves a model alias to its ID.', + aiMetadata: { + description: + "Reads a single Claude model by ID or alias (e.g. 'claude-3-7-sonnet-latest'), returning its resolved ID, display name, and release date. Use to confirm a model ID is valid or resolve an alias before using it elsewhere. Idempotent: it only reads data.", + idempotent: true, + }, + props: { + modelId: Property.ShortText({ + displayName: 'Model ID', + description: 'The model ID or alias to look up.', + required: true, + }), + }, + outputSchema: getModelActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + return anthropic.models.retrieve(context.propsValue.modelId); + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/list-message-batches.ts b/packages/pieces/community/claude/src/lib/actions/list-message-batches.ts new file mode 100644 index 000000000000..63625e8593a2 --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/list-message-batches.ts @@ -0,0 +1,50 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { listMessageBatchesActionOutputSchema } from '../output-schemas'; + +export const listMessageBatchesAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'list_message_batches', + classification: 'SEARCH', + displayName: 'List Message Batches', + description: 'Lists message batches, most recently created first.', + aiMetadata: { + description: + 'Lists message batches in the workspace, most recently created first, returning each batch\'s ID and processing_status. Use to discover a batch ID before calling Get Message Batch, Get Message Batch Results, Cancel Message Batch, or Delete Message Batch. Idempotent: it only reads data.', + idempotent: true, + }, + props: { + limit: Property.Number({ + displayName: 'Limit', + description: 'Number of batches to return per page (default 20, max 1000).', + required: false, + }), + afterId: Property.ShortText({ + displayName: 'After ID', + description: 'Return the page of batches immediately after this batch ID.', + required: false, + }), + beforeId: Property.ShortText({ + displayName: 'Before ID', + description: 'Return the page of batches immediately before this batch ID.', + required: false, + }), + }, + outputSchema: listMessageBatchesActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + const page = await anthropic.messages.batches.list({ + limit: context.propsValue.limit, + after_id: context.propsValue.afterId, + before_id: context.propsValue.beforeId, + }); + return { + data: page.data, + has_more: page.has_more, + first_id: page.first_id, + last_id: page.last_id, + }; + }, +}); diff --git a/packages/pieces/community/claude/src/lib/actions/list-models.ts b/packages/pieces/community/claude/src/lib/actions/list-models.ts new file mode 100644 index 000000000000..928c170e17bf --- /dev/null +++ b/packages/pieces/community/claude/src/lib/actions/list-models.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import Anthropic from '@anthropic-ai/sdk'; +import { claudeAuth } from '../auth'; +import { listModelsActionOutputSchema } from '../output-schemas'; + +export const listModelsAction = createAction({ + audience: 'ai', + auth: claudeAuth, + name: 'list_models', + classification: 'SEARCH', + displayName: 'List Models', + description: 'Lists the Claude models available to this API key.', + aiMetadata: { + description: + "Lists the Claude models available for use, most recently released first, returning each model's ID and display name. Use to discover a valid model ID before calling Ask Claude, Extract Structured Data, or Create Message Batch. Idempotent: it only reads data.", + idempotent: true, + }, + props: {}, + outputSchema: listModelsActionOutputSchema, + async run(context) { + const anthropic = new Anthropic({ apiKey: context.auth.secret_text }); + const models = []; + for await (const model of anthropic.models.list({ limit: 1000 })) { + models.push(model); + } + return { models }; + }, +}); diff --git a/packages/pieces/community/claude/src/lib/output-schemas.ts b/packages/pieces/community/claude/src/lib/output-schemas.ts index 349721c9ebed..1b80e6fa3829 100644 --- a/packages/pieces/community/claude/src/lib/output-schemas.ts +++ b/packages/pieces/community/claude/src/lib/output-schemas.ts @@ -31,3 +31,152 @@ export const extractStructuredDataActionOutputSchema: OutputSchema = { }, ], }; + +const capabilitySupportFields: OutputSchema['fields'] = [ + { key: 'supported', label: 'Supported', format: 'boolean' }, +]; + +const modelCapabilitiesFields: OutputSchema['fields'] = [ + { key: 'batch', label: 'Batch API', children: capabilitySupportFields }, + { key: 'citations', label: 'Citations', children: capabilitySupportFields }, + { key: 'code_execution', label: 'Code Execution', children: capabilitySupportFields }, + { key: 'image_input', label: 'Image Input', children: capabilitySupportFields }, + { key: 'pdf_input', label: 'PDF Input', children: capabilitySupportFields }, + { key: 'structured_outputs', label: 'Structured Outputs', children: capabilitySupportFields }, + { + key: 'thinking', + label: 'Extended Thinking', + children: [ + { key: 'supported', label: 'Supported', format: 'boolean' }, + { + key: 'types', + label: 'Types', + children: [ + { key: 'adaptive', label: 'Adaptive', children: capabilitySupportFields }, + { key: 'enabled', label: 'Enabled', children: capabilitySupportFields }, + ], + }, + ], + }, +]; + +const modelFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Model ID' }, + { key: 'display_name', label: 'Display Name' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'max_input_tokens', label: 'Max Input Tokens', format: 'number' }, + { key: 'max_tokens', label: 'Max Output Tokens', format: 'number' }, + { key: 'capabilities', label: 'Capabilities', children: modelCapabilitiesFields }, +]; + +const requestCountsFields: OutputSchema['fields'] = [ + { key: 'processing', label: 'Processing', format: 'number' }, + { key: 'succeeded', label: 'Succeeded', format: 'number' }, + { key: 'errored', label: 'Errored', format: 'number' }, + { key: 'canceled', label: 'Canceled', format: 'number' }, + { key: 'expired', label: 'Expired', format: 'number' }, +]; + +const messageBatchFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Batch ID' }, + { key: 'processing_status', label: 'Status' }, + { key: 'request_counts', label: 'Request Counts', children: requestCountsFields }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'expires_at', label: 'Expires At', format: 'datetime' }, + { key: 'ended_at', label: 'Ended At', format: 'datetime' }, + { key: 'archived_at', label: 'Archived At', format: 'datetime' }, + { key: 'cancel_initiated_at', label: 'Cancel Initiated At', format: 'datetime' }, + { key: 'results_url', label: 'Results URL', format: 'url' }, +]; + +export const listModelsActionOutputSchema: OutputSchema = { + fields: [{ key: 'models', label: 'Models', labelKey: 'display_name', listItems: modelFields }], +}; + +export const getModelActionOutputSchema: OutputSchema = { fields: modelFields }; + +export const createMessageBatchActionOutputSchema: OutputSchema = { fields: messageBatchFields }; + +export const getMessageBatchActionOutputSchema: OutputSchema = { fields: messageBatchFields }; + +export const listMessageBatchesActionOutputSchema: OutputSchema = { + fields: [ + { key: 'data', label: 'Batches', labelKey: 'id', listItems: messageBatchFields }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'first_id', label: 'First ID' }, + { key: 'last_id', label: 'Last ID' }, + ], +}; + +export const cancelMessageBatchActionOutputSchema: OutputSchema = { fields: messageBatchFields }; + +export const deleteMessageBatchActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Batch ID' }, + { key: 'type', label: 'Type' }, + ], +}; + +export const getMessageBatchResultsActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Results', + labelKey: 'custom_id', + listItems: [ + { key: 'custom_id', label: 'Custom ID' }, + { + key: 'result', + label: 'Result', + children: [ + { key: 'type', label: 'Type' }, + { + key: 'message', + label: 'Message', + children: [ + { key: 'id', label: 'Message ID' }, + { key: 'model', label: 'Model' }, + { key: 'stop_reason', label: 'Stop Reason' }, + { + key: 'content', + label: 'Content', + listItems: [ + { key: 'type', label: 'Type' }, + { key: 'text', label: 'Text' }, + ], + }, + { + key: 'usage', + label: 'Usage', + children: [ + { key: 'input_tokens', label: 'Input Tokens', format: 'number' }, + { key: 'output_tokens', label: 'Output Tokens', format: 'number' }, + ], + }, + ], + }, + { + key: 'error', + label: 'Error', + children: [ + { key: 'request_id', label: 'Request ID' }, + { + key: 'error', + label: 'Error', + children: [ + { key: 'type', label: 'Type' }, + { key: 'message', label: 'Message' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +export const countTokensActionOutputSchema: OutputSchema = { + fields: [{ key: 'input_tokens', label: 'Input Tokens', format: 'number' }], +}; From ae2a50a0c4279ffe68db7839c11f646163603250 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Tue, 22 Sep 2026 14:11:47 +0300 Subject: [PATCH 2/7] feat(pushover): let agents send, escalate and manage delivery groups (#15694) --- .../pieces/community/pushover/package.json | 2 +- .../pushover/src/i18n/translation.json | 115 ++++- .../pieces/community/pushover/src/index.ts | 44 +- .../src/lib/actions/add-user-to-group.ts | 58 +++ .../src/lib/actions/assign-license.ts | 66 +++ .../cancel-emergency-retries-by-tag.ts | 35 ++ .../lib/actions/cancel-emergency-retries.ts | 37 ++ .../src/lib/actions/create-delivery-group.ts | 128 +++++ .../src/lib/actions/disable-group-user.ts | 45 ++ .../src/lib/actions/enable-group-user.ts | 45 ++ .../src/lib/actions/get-app-limits.ts | 28 ++ .../src/lib/actions/get-delivery-group.ts | 36 ++ .../src/lib/actions/get-license-credits.ts | 28 ++ .../src/lib/actions/get-receipt-status.ts | 37 ++ .../src/lib/actions/list-delivery-groups.ts | 43 ++ .../lib/actions/list-notification-sounds.ts | 45 ++ .../actions/migrate-user-to-subscription.ts | 65 +++ .../src/lib/actions/remove-user-from-group.ts | 45 ++ .../src/lib/actions/rename-delivery-group.ts | 44 ++ .../src/lib/actions/send-notification.ts | 48 +- .../src/lib/actions/send-push-message.ts | 188 +++++++ .../pushover/src/lib/actions/update-glance.ts | 89 ++++ .../src/lib/actions/validate-user-key.ts | 51 ++ .../pushover/src/lib/common/index.ts | 331 +++++++++++++ .../pushover/src/lib/output-schemas.ts | 467 ++++++++++++++++++ 25 files changed, 2086 insertions(+), 34 deletions(-) create mode 100644 packages/pieces/community/pushover/src/lib/actions/add-user-to-group.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/assign-license.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries-by-tag.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/create-delivery-group.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/disable-group-user.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/enable-group-user.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/get-app-limits.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/get-delivery-group.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/get-license-credits.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/get-receipt-status.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/list-delivery-groups.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/list-notification-sounds.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/migrate-user-to-subscription.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/remove-user-from-group.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/rename-delivery-group.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/send-push-message.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/update-glance.ts create mode 100644 packages/pieces/community/pushover/src/lib/actions/validate-user-key.ts create mode 100644 packages/pieces/community/pushover/src/lib/common/index.ts diff --git a/packages/pieces/community/pushover/package.json b/packages/pieces/community/pushover/package.json index c4bfc18feacd..66e862be7b3e 100644 --- a/packages/pieces/community/pushover/package.json +++ b/packages/pieces/community/pushover/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-pushover", - "version": "0.2.8", + "version": "0.2.9", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/pushover/src/i18n/translation.json b/packages/pieces/community/pushover/src/i18n/translation.json index c765d41f4ea3..cefe1d56675f 100644 --- a/packages/pieces/community/pushover/src/i18n/translation.json +++ b/packages/pieces/community/pushover/src/i18n/translation.json @@ -6,7 +6,45 @@ "Pushover User Key": "Pushover User Key", "\n To obtain the api token:\n\n 1. Log in to Pushover.\n 2. Click on your Application or on Create an Application/API Token\n 3. Copy the API Token/Key.\n\n To obtain the user key:\n 1. Log in to Pushover\n 2. Copy your Your User Key\n\n Note if you want to send the message to your group, you should specify a group key instead of the user key\n ": "\n To obtain the api token:\n\n 1. Log in to Pushover.\n 2. Click on your Application or on Create an Application/API Token\n 3. Copy the API Token/Key.\n\n To obtain the user key:\n 1. Log in to Pushover\n 2. Copy your Your User Key\n\n Note if you want to send the message to your group, you should specify a group key instead of the user key\n ", "Send Notification": "Send Notification", + "Send Push Message": "Send Push Message", + "Get Emergency Receipt Status": "Get Emergency Receipt Status", + "Cancel Emergency Retries": "Cancel Emergency Retries", + "Cancel Emergency Retries by Tag": "Cancel Emergency Retries by Tag", + "Validate User or Group Key": "Validate User or Group Key", + "Get Monthly Message Limits": "Get Monthly Message Limits", + "Update Glance Widget": "Update Glance Widget", + "List Delivery Groups": "List Delivery Groups", + "Get Delivery Group": "Get Delivery Group", + "Create Delivery Group": "Create Delivery Group", + "Rename Delivery Group": "Rename Delivery Group", + "Add User to Delivery Group": "Add User to Delivery Group", + "Remove User from Delivery Group": "Remove User from Delivery Group", + "Disable Group User": "Disable Group User", + "Enable Group User": "Enable Group User", + "Get License Credits": "Get License Credits", + "Assign License": "Assign License", + "List Notification Sounds": "List Notification Sounds", + "Migrate User to Subscription": "Migrate User to Subscription", "Send a notification to Pushover": "Send a notification to Pushover", + "Send a push message through Pushover with the full parameter set": "Send a push message through Pushover with the full parameter set", + "Read the acknowledgement status of an emergency notification receipt": "Read the acknowledgement status of an emergency notification receipt", + "Stop the retry loop of one emergency notification receipt": "Stop the retry loop of one emergency notification receipt", + "Stop the retry loops of every emergency notification carrying a tag": "Stop the retry loops of every emergency notification carrying a tag", + "Check whether a Pushover user or group key is valid and reachable": "Check whether a Pushover user or group key is valid and reachable", + "Read the monthly message allowance and remaining quota of the application": "Read the monthly message allowance and remaining quota of the application", + "Update the Pushover glance widget fields on the user devices": "Update the Pushover glance widget fields on the user devices", + "List the delivery groups owned by the Pushover account": "List the delivery groups owned by the Pushover account", + "Read the name and members of one Pushover delivery group": "Read the name and members of one Pushover delivery group", + "Create a Pushover delivery group, or return the existing one with that name": "Create a Pushover delivery group, or return the existing one with that name", + "Change the name of a Pushover delivery group": "Change the name of a Pushover delivery group", + "Add a user key as a member of a Pushover delivery group": "Add a user key as a member of a Pushover delivery group", + "Permanently remove a member from a Pushover delivery group": "Permanently remove a member from a Pushover delivery group", + "Temporarily stop a group member from receiving group messages": "Temporarily stop a group member from receiving group messages", + "Resume group deliveries to a suspended group member": "Resume group deliveries to a suspended group member", + "Read how many prepaid Pushover license credits remain": "Read how many prepaid Pushover license credits remain", + "Spend a prepaid credit to license a Pushover user": "Spend a prepaid credit to license a Pushover user", + "List the notification sound identifiers Pushover accepts": "List the notification sound identifiers Pushover accepts", + "Convert an existing user key into a subscribed user key": "Convert an existing user key into a subscribed user key", "Title": "Title", "Message": "Message", "Enable HTML": "Enable HTML", @@ -17,6 +55,28 @@ "URL Title": "URL Title", "Timestamp": "Timestamp", "Device": "Device", + "Time To Live": "Time To Live", + "Sound": "Sound", + "Monospace": "Monospace", + "Tags": "Tags", + "Callback URL": "Callback URL", + "Attachment (Base64)": "Attachment (Base64)", + "Attachment Type": "Attachment Type", + "Receipt": "Receipt", + "Tag": "Tag", + "User or Group Key": "User or Group Key", + "Text": "Text", + "Subtext": "Subtext", + "Count": "Count", + "Percent": "Percent", + "Group Key": "Group Key", + "Name": "Name", + "New Name": "New Name", + "Memo": "Memo", + "Email": "Email", + "Operating System": "Operating System", + "Subscription Code": "Subscription Code", + "Device Name": "Device Name", "The title of the notification": "The title of the notification", "The message to send": "The message to send", "To enable HTML parsing": "To enable HTML parsing", @@ -26,5 +86,58 @@ "A supplementary URL to show with your message.": "A supplementary URL to show with your message.", "A title for the URL specified as the url input parameter, otherwise just the URL is shown.": "A title for the URL specified as the url input parameter, otherwise just the URL is shown.", "a Unix timestamp of a time to display instead of when our API received it.": "a Unix timestamp of a time to display instead of when our API received it.", - "The name of one of your devices to send just to that device instead of all devices.": "The name of one of your devices to send just to that device instead of all devices." + "The name of one of your devices to send just to that device instead of all devices.": "The name of one of your devices to send just to that device instead of all devices.", + "Body of the notification. Required.": "Body of the notification. Required.", + "Title shown above the message. Defaults to the application name when omitted.": "Title shown above the message. Defaults to the application name when omitted.", + "Delivery urgency. 2 (emergency) additionally requires Retry and Expire, ignores Time To Live, and returns a receipt.": "Delivery urgency. 2 (emergency) additionally requires Retry and Expire, ignores Time To Live, and returns a receipt.", + "Emergency priority only. Seconds between repeat deliveries, minimum 30. Pushover stops after 50 retries even if Expire has not elapsed.": "Emergency priority only. Seconds between repeat deliveries, minimum 30. Pushover stops after 50 retries even if Expire has not elapsed.", + "Emergency priority only. Seconds to keep retrying for, maximum 10800.": "Emergency priority only. Seconds to keep retrying for, maximum 10800.", + "Seconds after which the notification is auto-deleted from the device. Ignored at emergency priority (2).": "Seconds after which the notification is auto-deleted from the device. Ignored at emergency priority (2).", + "Notification sound. Leave on the default option to omit the parameter so the recipient device sound applies.": "Notification sound. Leave on the default option to omit the parameter so the recipient device sound applies.", + "Supplementary URL shown with the message, for example 'https://status.example.com/incident/42'.": "Supplementary URL shown with the message, for example 'https://status.example.com/incident/42'.", + "Link text for the supplementary URL. The raw URL is shown when omitted.": "Link text for the supplementary URL. The raw URL is shown when omitted.", + "Deliver only to this device name, for example 'iphone'. All devices receive it when omitted.": "Deliver only to this device name, for example 'iphone'. All devices receive it when omitted.", + "Unix timestamp to display instead of the receive time, for example '1767225600'.": "Unix timestamp to display instead of the receive time, for example '1767225600'.", + "Parse a limited HTML subset in the message. Cannot be combined with Monospace.": "Parse a limited HTML subset in the message. Cannot be combined with Monospace.", + "Render the message in a monospace font. Cannot be combined with Enable HTML.": "Render the message in a monospace font. Cannot be combined with Enable HTML.", + "Comma-separated tags stored with an emergency notification, for example 'deploy,prod'. Required upfront if you intend to use Cancel Emergency Retries by Tag.": "Comma-separated tags stored with an emergency notification, for example 'deploy,prod'. Required upfront if you intend to use Cancel Emergency Retries by Tag.", + "Emergency priority only. Pushover POSTs to this URL once the notification is acknowledged.": "Emergency priority only. Pushover POSTs to this URL once the notification is acknowledged.", + "Base64-encoded image to attach, maximum 5242880 bytes decoded. Requires Attachment Type.": "Base64-encoded image to attach, maximum 5242880 bytes decoded. Requires Attachment Type.", + "MIME type of the attachment, for example 'image/jpeg'. Required when an attachment is supplied.": "MIME type of the attachment, for example 'image/jpeg'. Required when an attachment is supplied.", + "Receipt id returned by Send Push Message at emergency priority, for example rXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.": "Receipt id returned by Send Push Message at emergency priority, for example rXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.", + "Receipt id returned by Send Push Message at emergency priority (2).": "Receipt id returned by Send Push Message at emergency priority (2).", + "A single tag that was supplied in the Tags parameter at send time, for example deploy. One tag only, with no spaces or slashes.": "A single tag that was supplied in the Tags parameter at send time, for example deploy. One tag only, with no spaces or slashes.", + "The 30-character user or group key to validate. Defaults to the key stored on the connection when left blank.": "The 30-character user or group key to validate. Defaults to the key stored on the connection when left blank.", + "Optional device name to validate alongside the key, for example iphone.": "Optional device name to validate alongside the key, for example iphone.", + "Short description of the data, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.": "Short description of the data, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.", + "Main line of text, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.": "Main line of text, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.", + "Second line of text, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.": "Second line of text, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.", + "Integer shown on the widget, may be negative. Omit to leave unchanged.": "Integer shown on the widget, may be negative. Omit to leave unchanged.", + "Integer from 0 to 100 rendered as a progress bar. Omit to leave unchanged.": "Integer from 0 to 100 rendered as a progress bar. Omit to leave unchanged.", + "Update only this device name. All devices are updated when omitted.": "Update only this device name. All devices are updated when omitted.", + "The 30-character delivery group key. Resolve it with List Delivery Groups; this is not the user key stored on the connection.": "The 30-character delivery group key. Resolve it with List Delivery Groups; this is not the user key stored on the connection.", + "Name of the delivery group, for example On-Call Engineers. Matching against existing groups ignores case, and the name is returned exactly as Pushover stores it.": "Name of the delivery group, for example On-Call Engineers. Matching against existing groups ignores case, and the name is returned exactly as Pushover stores it.", + "The 30-character delivery group key. Resolve it with List Delivery Groups.": "The 30-character delivery group key. Resolve it with List Delivery Groups.", + "The new group name, for example Weekend On-Call.": "The new group name, for example Weekend On-Call.", + "The 30-character user key of the person to add.": "The 30-character user key of the person to add.", + "Limit group deliveries to this one device of the user. All of their devices receive them when omitted.": "Limit group deliveries to this one device of the user. All of their devices receive them when omitted.", + "Note stored with the membership, maximum 200 characters, for example Primary on-call.": "Note stored with the membership, maximum 200 characters, for example Primary on-call.", + "The 30-character user key of the member to remove. Confirm membership with Get Delivery Group first.": "The 30-character user key of the member to remove. Confirm membership with Get Delivery Group first.", + "The 30-character user key of the member to suspend. Confirm membership with Get Delivery Group first.": "The 30-character user key of the member to suspend. Confirm membership with Get Delivery Group first.", + "The 30-character user key of the suspended member. Confirm membership with Get Delivery Group first.": "The 30-character user key of the suspended member. Confirm membership with Get Delivery Group first.", + "The 30-character user key to license. Supply either this or Email, not both.": "The 30-character user key to license. Supply either this or Email, not both.", + "Pushover account email of the person to license, used when their user key is unknown. Supply either this or User Key, not both.": "Pushover account email of the person to license, used when their user key is unknown. Supply either this or User Key, not both.", + "Restrict the license to one platform. Any platform may be used when omitted.": "Restrict the license to one platform. Any platform may be used when omitted.", + "The subscription code shown on the Pushover subscription page, for example sABCDEFGHIJKLMNOPQRSTUVWXYZ.": "The subscription code shown on the Pushover subscription page, for example sABCDEFGHIJKLMNOPQRSTUVWXYZ.", + "The existing 30-character user key to migrate. Defaults to the key stored on the connection when left blank.": "The existing 30-character user key to migrate. Defaults to the key stored on the connection when left blank.", + "Migrate only this one device of the user instead of the whole account.": "Migrate only this one device of the user instead of the whole account.", + "Default sound to set on the migrated subscription. Resolve an identifier with List Notification Sounds.": "Default sound to set on the migrated subscription. Resolve an identifier with List Notification Sounds.", + "Lowest (-2, no notification)": "Lowest (-2, no notification)", + "Low (-1, no sound or vibration)": "Low (-1, no sound or vibration)", + "Normal (0)": "Normal (0)", + "High (1, bypasses quiet hours)": "High (1, bypasses quiet hours)", + "Emergency (2, repeats until acknowledged)": "Emergency (2, repeats until acknowledged)", + "Android": "Android", + "iOS": "iOS", + "Desktop": "Desktop" } \ No newline at end of file diff --git a/packages/pieces/community/pushover/src/index.ts b/packages/pieces/community/pushover/src/index.ts index d8cc61983a3a..3de8dae7c841 100644 --- a/packages/pieces/community/pushover/src/index.ts +++ b/packages/pieces/community/pushover/src/index.ts @@ -1,6 +1,25 @@ import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { sendNotification } from './lib/actions/send-notification'; +import { sendPushMessage } from './lib/actions/send-push-message'; +import { getReceiptStatus } from './lib/actions/get-receipt-status'; +import { cancelEmergencyRetries } from './lib/actions/cancel-emergency-retries'; +import { cancelEmergencyRetriesByTag } from './lib/actions/cancel-emergency-retries-by-tag'; +import { validateUserKey } from './lib/actions/validate-user-key'; +import { getAppLimits } from './lib/actions/get-app-limits'; +import { updateGlance } from './lib/actions/update-glance'; +import { listDeliveryGroups } from './lib/actions/list-delivery-groups'; +import { getDeliveryGroup } from './lib/actions/get-delivery-group'; +import { createDeliveryGroup } from './lib/actions/create-delivery-group'; +import { renameDeliveryGroup } from './lib/actions/rename-delivery-group'; +import { addUserToGroup } from './lib/actions/add-user-to-group'; +import { removeUserFromGroup } from './lib/actions/remove-user-from-group'; +import { disableGroupUser } from './lib/actions/disable-group-user'; +import { enableGroupUser } from './lib/actions/enable-group-user'; +import { getLicenseCredits } from './lib/actions/get-license-credits'; +import { assignLicense } from './lib/actions/assign-license'; +import { listNotificationSounds } from './lib/actions/list-notification-sounds'; +import { migrateUserToSubscription } from './lib/actions/migrate-user-to-subscription'; export const pushoverAuth = PieceAuth.CustomAuth({ description: ` @@ -37,9 +56,30 @@ export const pushover = createPiece({ logoUrl: 'https://cdn.activepieces.com/pieces/pushover.png', categories: [PieceCategory.COMMUNICATION], - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', authors: ["MyWay","Vitalini","kishanprmr","khaledmashaly","abuaboud"], auth: pushoverAuth, - actions: [sendNotification], + actions: [ + sendNotification, + sendPushMessage, + getReceiptStatus, + cancelEmergencyRetries, + cancelEmergencyRetriesByTag, + validateUserKey, + getAppLimits, + updateGlance, + listDeliveryGroups, + getDeliveryGroup, + createDeliveryGroup, + renameDeliveryGroup, + addUserToGroup, + removeUserFromGroup, + disableGroupUser, + enableGroupUser, + getLicenseCredits, + assignLicense, + listNotificationSounds, + migrateUserToSubscription, + ], triggers: [], }); diff --git a/packages/pieces/community/pushover/src/lib/actions/add-user-to-group.ts b/packages/pieces/community/pushover/src/lib/actions/add-user-to-group.ts new file mode 100644 index 000000000000..3dc1de85ed0a --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/add-user-to-group.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { addUserToGroupOutputSchema } from '../output-schemas'; + +export const addUserToGroup = createAction({ + auth: pushoverAuth, + name: 'add_user_to_group', + classification: 'WRITE', + displayName: 'Add User to Delivery Group', + description: 'Add a user key as a member of a Pushover delivery group', + audience: 'ai', + aiMetadata: { + description: + 'Add one user key to a delivery group so the group key also delivers to them. Resolve the group key with List Delivery Groups and validate the user key with Validate User or Group Key first. Not idempotent: adding a user who is already a member fails, so check the members with Get Delivery Group before retrying. To restore a suspended member use Enable Group User instead.', + idempotent: false, + }, + props: { + group_key: Property.ShortText({ + displayName: 'Group Key', + description: + 'The 30-character delivery group key. Resolve it with List Delivery Groups.', + required: true, + }), + user: Property.ShortText({ + displayName: 'User Key', + description: 'The 30-character user key of the person to add.', + required: true, + }), + device: Property.ShortText({ + displayName: 'Device', + description: + 'Limit group deliveries to this one device of the user. All of their devices receive them when omitted.', + required: false, + }), + memo: Property.ShortText({ + displayName: 'Memo', + description: + 'Note stored with the membership, maximum 200 characters, for example Primary on-call.', + required: false, + }), + }, + outputSchema: addUserToGroupOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/groups/${encodeURIComponent(propsValue.group_key)}/add_user.json`, + body: { + token: auth.props.api_token, + user: propsValue.user, + ...(propsValue.device ? { device: propsValue.device } : {}), + ...(propsValue.memo ? { memo: propsValue.memo } : {}), + }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/assign-license.ts b/packages/pieces/community/pushover/src/lib/actions/assign-license.ts new file mode 100644 index 000000000000..9ba5e673797b --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/assign-license.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; + +export const assignLicense = createAction({ + auth: pushoverAuth, + name: 'assign_license', + classification: 'WRITE', + displayName: 'Assign License', + description: 'Spend a prepaid credit to license a Pushover user', + audience: 'ai', + aiMetadata: { + description: + 'Assign a Pushover license to one user, identified by either their user key or their account email. This spends one prepaid credit, costs real money and cannot be revoked: there is no unassign endpoint. Check the balance with Get License Credits first and only call it when a human has explicitly asked for this person to be licensed. Not idempotent: a repeat call spends another credit.', + idempotent: false, + }, + props: { + user: Property.ShortText({ + displayName: 'User Key', + description: + 'The 30-character user key to license. Supply either this or Email, not both.', + required: false, + }), + email: Property.ShortText({ + displayName: 'Email', + description: + 'Pushover account email of the person to license, used when their user key is unknown. Supply either this or User Key, not both.', + required: false, + }), + os: Property.StaticDropdown({ + displayName: 'Operating System', + description: + 'Restrict the license to one platform. Any platform may be used when omitted.', + required: false, + options: { + options: [ + { label: 'Android', value: 'Android' }, + { label: 'iOS', value: 'iOS' }, + { label: 'Desktop', value: 'Desktop' }, + ], + }, + }), + }, + async run({ auth, propsValue }) { + const hasUser = propsValue.user !== undefined && propsValue.user.length > 0; + const hasEmail = + propsValue.email !== undefined && propsValue.email.length > 0; + if (hasUser === hasEmail) { + throw new Error( + 'Assign License needs exactly one of User Key or Email to identify the recipient.' + ); + } + + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: '/licenses/assign.json', + body: { + token: auth.props.api_token, + ...(hasUser ? { user: propsValue.user } : {}), + ...(hasEmail ? { email: propsValue.email } : {}), + ...(propsValue.os ? { os: propsValue.os } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries-by-tag.ts b/packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries-by-tag.ts new file mode 100644 index 000000000000..621f918039c3 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries-by-tag.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { cancelEmergencyRetriesByTagOutputSchema } from '../output-schemas'; + +export const cancelEmergencyRetriesByTag = createAction({ + auth: pushoverAuth, + name: 'cancel_emergency_retries_by_tag', + classification: 'WRITE', + displayName: 'Cancel Emergency Retries by Tag', + description: 'Stop the retry loops of every emergency notification carrying a tag', + audience: 'ai', + aiMetadata: { + description: + 'Stop the retries of every still-active emergency (priority 2) notification sent with this tag. The tag must have been supplied in the Tags parameter of Send Push Message at send time; tags cannot be attached afterwards. Use Cancel Emergency Retries when you hold a single receipt instead. Safe to retry: it is a no-op once nothing with the tag is retrying.', + idempotent: true, + }, + props: { + tag: Property.ShortText({ + displayName: 'Tag', + description: + 'A single tag that was supplied in the Tags parameter at send time, for example deploy. One tag only, with no spaces or slashes.', + required: true, + }), + }, + outputSchema: cancelEmergencyRetriesByTagOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/receipts/cancel_by_tag/${encodeURIComponent(propsValue.tag)}.json`, + body: { token: auth.props.api_token }, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries.ts b/packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries.ts new file mode 100644 index 000000000000..62d31aa36288 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/cancel-emergency-retries.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { cancelEmergencyRetriesOutputSchema } from '../output-schemas'; + +export const cancelEmergencyRetries = createAction({ + auth: pushoverAuth, + name: 'cancel_emergency_retries', + classification: 'WRITE', + displayName: 'Cancel Emergency Retries', + description: 'Stop the retry loop of one emergency notification receipt', + audience: 'ai', + aiMetadata: { + description: + 'Stop Pushover from resending one emergency (priority 2) notification, identified by the receipt returned by Send Push Message. Use it once the underlying incident is resolved so the recipient stops being re-alerted; it deletes nothing and already-delivered notifications stay. To stop many notifications at once use Cancel Emergency Retries by Tag. Safe to retry: cancelling an already-cancelled receipt is a no-op.', + idempotent: true, + }, + props: { + receipt: Property.ShortText({ + displayName: 'Receipt', + description: + 'Receipt id returned by Send Push Message at emergency priority (2).', + required: true, + }), + }, + outputSchema: cancelEmergencyRetriesOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/receipts/${encodeURIComponent(propsValue.receipt)}/cancel.json`, + body: { token: auth.props.api_token }, + errorHint: + 'Receipts expire after about one week and only exist for priority 2 notifications.', + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/create-delivery-group.ts b/packages/pieces/community/pushover/src/lib/actions/create-delivery-group.ts new file mode 100644 index 000000000000..a943c21df0d3 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/create-delivery-group.ts @@ -0,0 +1,128 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { + GROUP_OWNERSHIP_HINT, + PushoverRequestError, + pushoverApiCall, +} from '../common'; +import { createDeliveryGroupOutputSchema } from '../output-schemas'; + +export const createDeliveryGroup = createAction({ + auth: pushoverAuth, + name: 'create_delivery_group', + classification: 'WRITE', + displayName: 'Create Delivery Group', + description: 'Create a Pushover delivery group, or return the existing one with that name', + audience: 'ai', + aiMetadata: { + description: + 'Get or create a delivery group by name and return its group key. Pushover allows only one group per name on an account, so this action lists the existing groups first and returns the case-insensitive match with created=false instead of failing; it only creates when the name is unused, returning created=true. Safe to retry: a second call with the same name returns the same group key. Add members afterwards with Add User to Delivery Group.', + idempotent: true, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: + 'Name of the delivery group, for example On-Call Engineers. Matching against existing groups ignores case, and the name is returned exactly as Pushover stores it.', + required: true, + }), + }, + outputSchema: createDeliveryGroupOutputSchema, + async run({ auth, propsValue }) { + const existing = await findGroupByName({ + apiToken: auth.props.api_token, + name: propsValue.name, + }); + if (existing !== undefined) { + return { + group: existing.group, + name: existing.name, + created: false, + }; + } + + try { + const created = await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: '/groups.json', + body: { + token: auth.props.api_token, + name: propsValue.name, + }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + + return { + group: created.group, + name: propsValue.name, + created: true, + }; + } catch (error) { + if (!isNameAlreadyTakenError(error)) { + throw error; + } + const raced = await findGroupByName({ + apiToken: auth.props.api_token, + name: propsValue.name, + }); + if (raced === undefined) { + throw error; + } + return { + group: raced.group, + name: raced.name, + created: false, + }; + } + }, +}); + +async function findGroupByName({ + apiToken, + name, +}: FindGroupByNameParams): Promise { + const existingGroups = await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: '/groups.json', + queryParams: { token: apiToken }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + const wanted = name.trim().toLowerCase(); + return (existingGroups.groups ?? []).find( + (group) => group.name.trim().toLowerCase() === wanted + ); +} + +function isNameAlreadyTakenError(error: unknown): boolean { + if (error instanceof PushoverRequestError) { + if (error.status !== undefined && error.status >= 500) { + return false; + } + if (error.vendorErrors.length > 0) { + return error.vendorErrors.some((entry) => NAME_TAKEN_PATTERN.test(entry)); + } + } + const message = error instanceof Error ? error.message : String(error); + return NAME_TAKEN_PATTERN.test(message); +} + +const NAME_TAKEN_PATTERN = /already (exists|been taken|in use)|has already been|must be unique/i; + +type PushoverGroup = { + group: string; + name: string; +}; + +type GroupsResponse = { + groups: PushoverGroup[]; +}; + +type FindGroupByNameParams = { + apiToken: string; + name: string; +}; + +type CreateGroupResponse = { + group: string; +}; diff --git a/packages/pieces/community/pushover/src/lib/actions/disable-group-user.ts b/packages/pieces/community/pushover/src/lib/actions/disable-group-user.ts new file mode 100644 index 000000000000..edbccc04b08e --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/disable-group-user.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { disableGroupUserOutputSchema } from '../output-schemas'; + +export const disableGroupUser = createAction({ + auth: pushoverAuth, + name: 'disable_group_user', + classification: 'WRITE', + displayName: 'Disable Group User', + description: 'Temporarily stop a group member from receiving group messages', + audience: 'ai', + aiMetadata: { + description: + 'Suspend one member of a delivery group so group messages skip them while their membership, device and memo are kept. This is the reversible alternative to Remove User from Delivery Group: undo it with Enable Group User. Safe to retry: disabling an already-disabled member converges on the same state.', + idempotent: true, + }, + props: { + group_key: Property.ShortText({ + displayName: 'Group Key', + description: + 'The 30-character delivery group key. Resolve it with List Delivery Groups.', + required: true, + }), + user: Property.ShortText({ + displayName: 'User Key', + description: + 'The 30-character user key of the member to suspend. Confirm membership with Get Delivery Group first.', + required: true, + }), + }, + outputSchema: disableGroupUserOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/groups/${encodeURIComponent(propsValue.group_key)}/disable_user.json`, + body: { + token: auth.props.api_token, + user: propsValue.user, + }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/enable-group-user.ts b/packages/pieces/community/pushover/src/lib/actions/enable-group-user.ts new file mode 100644 index 000000000000..b6315a9fff4d --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/enable-group-user.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { enableGroupUserOutputSchema } from '../output-schemas'; + +export const enableGroupUser = createAction({ + auth: pushoverAuth, + name: 'enable_group_user', + classification: 'WRITE', + displayName: 'Enable Group User', + description: 'Resume group deliveries to a suspended group member', + audience: 'ai', + aiMetadata: { + description: + 'Re-enable a member of a delivery group who was suspended with Disable Group User, so group messages reach them again. It only works on someone who is still a member: a user removed with Remove User from Delivery Group has to be re-added with Add User to Delivery Group. Safe to retry: enabling an already-enabled member converges on the same state.', + idempotent: true, + }, + props: { + group_key: Property.ShortText({ + displayName: 'Group Key', + description: + 'The 30-character delivery group key. Resolve it with List Delivery Groups.', + required: true, + }), + user: Property.ShortText({ + displayName: 'User Key', + description: + 'The 30-character user key of the suspended member. Confirm membership with Get Delivery Group first.', + required: true, + }), + }, + outputSchema: enableGroupUserOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/groups/${encodeURIComponent(propsValue.group_key)}/enable_user.json`, + body: { + token: auth.props.api_token, + user: propsValue.user, + }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/get-app-limits.ts b/packages/pieces/community/pushover/src/lib/actions/get-app-limits.ts new file mode 100644 index 000000000000..b48d4032b4a0 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/get-app-limits.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { getAppLimitsOutputSchema } from '../output-schemas'; + +export const getAppLimits = createAction({ + auth: pushoverAuth, + name: 'get_app_limits', + classification: 'READ', + displayName: 'Get Monthly Message Limits', + description: 'Read the monthly message allowance and remaining quota of the application', + audience: 'ai', + aiMetadata: { + description: + 'Read the monthly message allowance of the application, how many messages remain, and the Unix timestamp when the counter resets. Use it before a bulk send, or to explain a 429 from Send Push Message, which means the monthly quota is exhausted rather than transient throttling. Takes no input. Safe to retry.', + idempotent: true, + }, + props: {}, + outputSchema: getAppLimitsOutputSchema, + async run({ auth }) { + return await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: '/apps/limits.json', + queryParams: { token: auth.props.api_token }, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/get-delivery-group.ts b/packages/pieces/community/pushover/src/lib/actions/get-delivery-group.ts new file mode 100644 index 000000000000..9e207ad374cf --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/get-delivery-group.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { getDeliveryGroupOutputSchema } from '../output-schemas'; + +export const getDeliveryGroup = createAction({ + auth: pushoverAuth, + name: 'get_delivery_group', + classification: 'READ', + displayName: 'Get Delivery Group', + description: 'Read the name and members of one Pushover delivery group', + audience: 'ai', + aiMetadata: { + description: + 'Read one delivery group: its name and every member, including each member device, memo and whether the member is currently disabled. Use it to check membership before adding, removing, disabling or enabling a user. Resolve the group key with List Delivery Groups first. Safe to retry.', + idempotent: true, + }, + props: { + group_key: Property.ShortText({ + displayName: 'Group Key', + description: + 'The 30-character delivery group key. Resolve it with List Delivery Groups; this is not the user key stored on the connection.', + required: true, + }), + }, + outputSchema: getDeliveryGroupOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: `/groups/${encodeURIComponent(propsValue.group_key)}.json`, + queryParams: { token: auth.props.api_token }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/get-license-credits.ts b/packages/pieces/community/pushover/src/lib/actions/get-license-credits.ts new file mode 100644 index 000000000000..640b221ea4bd --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/get-license-credits.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { getLicenseCreditsOutputSchema } from '../output-schemas'; + +export const getLicenseCredits = createAction({ + auth: pushoverAuth, + name: 'get_license_credits', + classification: 'READ', + displayName: 'Get License Credits', + description: 'Read how many prepaid Pushover license credits remain', + audience: 'ai', + aiMetadata: { + description: + 'Read how many prepaid Pushover license credits the application still holds. Call it before Assign License, which spends one credit irreversibly. Takes no input. Safe to retry.', + idempotent: true, + }, + props: {}, + outputSchema: getLicenseCreditsOutputSchema, + async run({ auth }) { + return await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: '/licenses.json', + queryParams: { token: auth.props.api_token }, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/get-receipt-status.ts b/packages/pieces/community/pushover/src/lib/actions/get-receipt-status.ts new file mode 100644 index 000000000000..cc07ce8c0fa6 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/get-receipt-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { getReceiptStatusOutputSchema } from '../output-schemas'; + +export const getReceiptStatus = createAction({ + auth: pushoverAuth, + name: 'get_receipt_status', + classification: 'READ', + displayName: 'Get Emergency Receipt Status', + description: 'Read the acknowledgement status of an emergency notification receipt', + audience: 'ai', + aiMetadata: { + description: + 'Read whether an emergency (priority 2) notification has been acknowledged, and whether its retries have expired. The receipt comes from Send Push Message sent at priority 2; no other priority produces one. This is a single read, not a wait loop: re-call it at most once every 5 seconds until acknowledged is 1 or expired is 1. Receipts stay valid for up to one week and older ids return an error. Safe to retry.', + idempotent: true, + }, + props: { + receipt: Property.ShortText({ + displayName: 'Receipt', + description: + 'Receipt id returned by Send Push Message at emergency priority, for example rXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.', + required: true, + }), + }, + outputSchema: getReceiptStatusOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: `/receipts/${encodeURIComponent(propsValue.receipt)}.json`, + queryParams: { token: auth.props.api_token }, + errorHint: + 'Receipts expire after about one week and only exist for priority 2 notifications.', + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/list-delivery-groups.ts b/packages/pieces/community/pushover/src/lib/actions/list-delivery-groups.ts new file mode 100644 index 000000000000..586af0d3457f --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/list-delivery-groups.ts @@ -0,0 +1,43 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { listDeliveryGroupsOutputSchema } from '../output-schemas'; + +export const listDeliveryGroups = createAction({ + auth: pushoverAuth, + name: 'list_delivery_groups', + classification: 'READ', + displayName: 'List Delivery Groups', + description: 'List the delivery groups owned by the Pushover account', + audience: 'ai', + aiMetadata: { + description: + 'List every delivery group on the account with its group key and name. This is the id resolver for every other group action: call it first to turn a group name into the group key that Get Delivery Group, Add User to Delivery Group and the rest require. Only groups owned by the same account as the application token are visible. Takes no input. Safe to retry.', + idempotent: true, + }, + props: {}, + outputSchema: listDeliveryGroupsOutputSchema, + async run({ auth }) { + const response = await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: '/groups.json', + queryParams: { token: auth.props.api_token }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + const groups = response.groups ?? []; + + return { + groups, + count: groups.length, + status: response.status, + request: response.request, + }; + }, +}); + +type GroupsResponse = { + groups: { group: string; name: string }[]; + status: number; + request: string; +}; diff --git a/packages/pieces/community/pushover/src/lib/actions/list-notification-sounds.ts b/packages/pieces/community/pushover/src/lib/actions/list-notification-sounds.ts new file mode 100644 index 000000000000..fc602962a6fc --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/list-notification-sounds.ts @@ -0,0 +1,45 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { listNotificationSoundsOutputSchema } from '../output-schemas'; + +export const listNotificationSounds = createAction({ + auth: pushoverAuth, + name: 'list_notification_sounds', + classification: 'READ', + displayName: 'List Notification Sounds', + description: 'List the notification sound identifiers Pushover accepts', + audience: 'ai', + aiMetadata: { + description: + 'List every sound identifier Pushover accepts, each with its human-readable name. Call it to resolve a sound before setting the Sound parameter of Send Push Message instead of guessing an identifier; omit the sound entirely to leave the recipient device default in place. Takes no input. Safe to retry.', + idempotent: true, + }, + props: {}, + outputSchema: listNotificationSoundsOutputSchema, + async run({ auth }) { + const response = await pushoverApiCall({ + method: HttpMethod.GET, + resourceUri: '/sounds.json', + queryParams: { token: auth.props.api_token }, + }); + const sounds = Object.entries(response.sounds ?? {}).map(([id, name]) => ({ + id, + name, + })); + + return { + sounds, + count: sounds.length, + status: response.status, + request: response.request, + }; + }, +}); + +type SoundsResponse = { + sounds: Record; + status: number; + request: string; +}; diff --git a/packages/pieces/community/pushover/src/lib/actions/migrate-user-to-subscription.ts b/packages/pieces/community/pushover/src/lib/actions/migrate-user-to-subscription.ts new file mode 100644 index 000000000000..9363cbec8ada --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/migrate-user-to-subscription.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; + +export const migrateUserToSubscription = createAction({ + auth: pushoverAuth, + name: 'migrate_user_to_subscription', + classification: 'WRITE', + displayName: 'Migrate User to Subscription', + description: 'Convert an existing user key into a subscribed user key', + audience: 'ai', + aiMetadata: { + description: + 'Convert a user key that already authorised this application into a subscription-specific user key, returning subscribed_user_key. Only meaningful for an application owner running a Pushover subscription, and it needs the subscription code from the subscription page; there is no programmatic way to subscribe someone who has not gone through the subscription URL. Send future messages to the returned key. Not idempotent: call it once per user and store the result.', + idempotent: false, + }, + props: { + subscription: Property.ShortText({ + displayName: 'Subscription Code', + description: + 'The subscription code shown on the Pushover subscription page, for example sABCDEFGHIJKLMNOPQRSTUVWXYZ.', + required: true, + }), + user: Property.ShortText({ + displayName: 'User Key', + description: + 'The existing 30-character user key to migrate. Defaults to the key stored on the connection when left blank.', + required: false, + }), + device_name: Property.ShortText({ + displayName: 'Device Name', + description: + 'Migrate only this one device of the user instead of the whole account.', + required: false, + }), + sound: Property.ShortText({ + displayName: 'Sound', + description: + 'Default sound to set on the migrated subscription. Resolve an identifier with List Notification Sounds.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const suppliedUser = propsValue.user; + const user = + suppliedUser !== undefined && suppliedUser.length > 0 + ? suppliedUser + : auth.props.user_key; + + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: '/subscriptions/migrate.json', + body: { + token: auth.props.api_token, + subscription: propsValue.subscription, + user, + ...(propsValue.device_name + ? { device_name: propsValue.device_name } + : {}), + ...(propsValue.sound ? { sound: propsValue.sound } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/remove-user-from-group.ts b/packages/pieces/community/pushover/src/lib/actions/remove-user-from-group.ts new file mode 100644 index 000000000000..37034ba60d30 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/remove-user-from-group.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { removeUserFromGroupOutputSchema } from '../output-schemas'; + +export const removeUserFromGroup = createAction({ + auth: pushoverAuth, + name: 'remove_user_from_group', + classification: 'WRITE', + displayName: 'Remove User from Delivery Group', + description: 'Permanently remove a member from a Pushover delivery group', + audience: 'ai', + aiMetadata: { + description: + 'Permanently remove a user key from a delivery group, discarding their device and memo settings. Prefer Disable Group User when the person should stop receiving messages only temporarily, since that is reversible with Enable Group User and this is not. Not idempotent: a repeat call for a user who is no longer a member fails with "user is not a member of this group", so confirm membership with Get Delivery Group before retrying. Re-adding afterwards is a fresh membership, not an undo.', + idempotent: false, + }, + props: { + group_key: Property.ShortText({ + displayName: 'Group Key', + description: + 'The 30-character delivery group key. Resolve it with List Delivery Groups.', + required: true, + }), + user: Property.ShortText({ + displayName: 'User Key', + description: + 'The 30-character user key of the member to remove. Confirm membership with Get Delivery Group first.', + required: true, + }), + }, + outputSchema: removeUserFromGroupOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/groups/${encodeURIComponent(propsValue.group_key)}/remove_user.json`, + body: { + token: auth.props.api_token, + user: propsValue.user, + }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/rename-delivery-group.ts b/packages/pieces/community/pushover/src/lib/actions/rename-delivery-group.ts new file mode 100644 index 000000000000..1b05fa97d62e --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/rename-delivery-group.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { GROUP_OWNERSHIP_HINT, pushoverApiCall } from '../common'; +import { renameDeliveryGroupOutputSchema } from '../output-schemas'; + +export const renameDeliveryGroup = createAction({ + auth: pushoverAuth, + name: 'rename_delivery_group', + classification: 'WRITE', + displayName: 'Rename Delivery Group', + description: 'Change the name of a Pushover delivery group', + audience: 'ai', + aiMetadata: { + description: + 'Rename one delivery group. The group key never changes, so existing flows keep delivering. Only one group per name may exist on an account, so renaming onto a name already in use fails: check with List Delivery Groups first. Safe to retry: renaming to the name it already has converges on the same state.', + idempotent: true, + }, + props: { + group_key: Property.ShortText({ + displayName: 'Group Key', + description: + 'The 30-character delivery group key. Resolve it with List Delivery Groups.', + required: true, + }), + name: Property.ShortText({ + displayName: 'New Name', + description: 'The new group name, for example Weekend On-Call.', + required: true, + }), + }, + outputSchema: renameDeliveryGroupOutputSchema, + async run({ auth, propsValue }) { + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: `/groups/${encodeURIComponent(propsValue.group_key)}/rename.json`, + body: { + token: auth.props.api_token, + name: propsValue.name, + }, + errorHint: GROUP_OWNERSHIP_HINT, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/send-notification.ts b/packages/pieces/community/pushover/src/lib/actions/send-notification.ts index 76df6e33a62f..9a08a4f5b61b 100644 --- a/packages/pieces/community/pushover/src/lib/actions/send-notification.ts +++ b/packages/pieces/community/pushover/src/lib/actions/send-notification.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { pushoverAuth } from '../..'; +import { buildMessageBody } from '../common'; import { sendNotificationActionOutputSchema } from '../output-schemas'; export const sendNotification = createAction({ @@ -9,7 +10,7 @@ export const sendNotification = createAction({ classification: 'WRITE', displayName: 'Send Notification', description: 'Send a notification to Pushover', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Sends a push notification through Pushover to the device(s) tied to the configured user/group key. Use to alert a person or group on their phone/desktop. The message body is required; optional priority (-2 to 2) controls urgency, and emergency priority (2) additionally requires retry and expire values. Not idempotent: each call delivers a new notification.', idempotent: false, @@ -74,38 +75,25 @@ export const sendNotification = createAction({ }, outputSchema: sendNotificationActionOutputSchema, async run({ auth, propsValue }) { - const baseUrl = 'https://api.pushover.net/1/messages.json'; - const apiToken = auth.props.api_token; - const userKey = auth.props.user_key; - - const title = propsValue.title; - const message = propsValue.message; - const html = propsValue.html; - const priority = propsValue.priority; - const url = propsValue.url; - const url_title = propsValue.url_title; - const timestamp = propsValue.timestamp; - const device = propsValue.device; - const retry = propsValue.retry; - const expire = propsValue.expire; + const body = buildMessageBody({ + apiToken: auth.props.api_token, + userKey: auth.props.user_key, + message: propsValue.message, + title: propsValue.title, + html: propsValue.html, + priority: propsValue.priority, + retry: propsValue.retry, + expire: propsValue.expire, + url: propsValue.url, + urlTitle: propsValue.url_title, + timestamp: propsValue.timestamp, + device: propsValue.device, + }); return await httpClient.sendRequest({ method: HttpMethod.POST, - url: baseUrl, - body: { - token: apiToken, - user: userKey, - title, - message, - html: html ? 1 : 0, - url, - url_title, - timestamp, - device, - ...(priority && { priority: +priority }), - ...(retry && { retry: +retry }), - ...(expire && { expire: +expire }), - }, + url: 'https://api.pushover.net/1/messages.json', + body, }); }, }); diff --git a/packages/pieces/community/pushover/src/lib/actions/send-push-message.ts b/packages/pieces/community/pushover/src/lib/actions/send-push-message.ts new file mode 100644 index 000000000000..a79adf7cb1e7 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/send-push-message.ts @@ -0,0 +1,188 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { buildMessageBody, pushoverApiCall } from '../common'; +import { sendPushMessageOutputSchema } from '../output-schemas'; + +export const sendPushMessage = createAction({ + auth: pushoverAuth, + name: 'send_push_message', + classification: 'WRITE', + displayName: 'Send Push Message', + description: 'Send a push message through Pushover with the full parameter set', + audience: 'ai', + aiMetadata: { + description: + 'Send a push notification to the user or group key on the connection, exposing every Pushover message parameter (sound, ttl, tags, attachment, monospace, callback). Emergency priority 2 requires both retry (>= 30 seconds) and expire (<= 10800 seconds), is capped at 50 total retries regardless of expire (retry=30 with expire=10800 therefore stops after about 25 minutes), ignores ttl, and is the only priority that returns a receipt for Get Emergency Receipt Status and the cancel actions. Not idempotent: every call delivers a new notification.', + idempotent: false, + }, + props: { + message: Property.LongText({ + displayName: 'Message', + description: 'Body of the notification. Required.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: + 'Title shown above the message. Defaults to the application name when omitted.', + required: false, + }), + priority: Property.StaticDropdown({ + displayName: 'Priority', + description: + 'Delivery urgency. 2 (emergency) additionally requires Retry and Expire, ignores Time To Live, and returns a receipt.', + required: false, + options: { + options: [ + { label: 'Lowest (-2, no notification)', value: -2 }, + { label: 'Low (-1, no sound or vibration)', value: -1 }, + { label: 'Normal (0)', value: 0 }, + { label: 'High (1, bypasses quiet hours)', value: 1 }, + { label: 'Emergency (2, repeats until acknowledged)', value: 2 }, + ], + }, + }), + retry: Property.Number({ + displayName: 'Retry', + description: + 'Emergency priority only. Seconds between repeat deliveries, minimum 30. Pushover stops after 50 retries even if Expire has not elapsed.', + required: false, + }), + expire: Property.Number({ + displayName: 'Expire', + description: + 'Emergency priority only. Seconds to keep retrying for, maximum 10800.', + required: false, + }), + ttl: Property.Number({ + displayName: 'Time To Live', + description: + 'Seconds after which the notification is auto-deleted from the device. Ignored at emergency priority (2).', + required: false, + }), + sound: Property.Dropdown({ + displayName: 'Sound', + description: + 'Notification sound. Leave on the default option to omit the parameter so the recipient device sound applies.', + required: false, + auth: pushoverAuth, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect a Pushover account first', + options: [], + }; + } + const response = await pushoverApiCall<{ + sounds: Record; + }>({ + method: HttpMethod.GET, + resourceUri: '/sounds.json', + queryParams: { token: auth.props.api_token }, + }); + return { + disabled: false, + options: [ + { label: "Device default (don't send a sound)", value: '' }, + ...Object.entries(response.sounds ?? {}).map( + ([value, label]) => ({ label, value }) + ), + ], + }; + }, + }), + url: Property.ShortText({ + displayName: 'URL', + description: + "Supplementary URL shown with the message, for example 'https://status.example.com/incident/42'.", + required: false, + }), + url_title: Property.ShortText({ + displayName: 'URL Title', + description: + 'Link text for the supplementary URL. The raw URL is shown when omitted.', + required: false, + }), + device: Property.ShortText({ + displayName: 'Device', + description: + "Deliver only to this device name, for example 'iphone'. All devices receive it when omitted.", + required: false, + }), + timestamp: Property.ShortText({ + displayName: 'Timestamp', + description: + "Unix timestamp to display instead of the receive time, for example '1767225600'.", + required: false, + }), + html: Property.Checkbox({ + displayName: 'Enable HTML', + description: + 'Parse a limited HTML subset in the message. Cannot be combined with Monospace.', + required: false, + }), + monospace: Property.Checkbox({ + displayName: 'Monospace', + description: + 'Render the message in a monospace font. Cannot be combined with Enable HTML.', + required: false, + }), + tags: Property.ShortText({ + displayName: 'Tags', + description: + "Comma-separated tags stored with an emergency notification, for example 'deploy,prod'. Required upfront if you intend to use Cancel Emergency Retries by Tag.", + required: false, + }), + callback: Property.ShortText({ + displayName: 'Callback URL', + description: + 'Emergency priority only. Pushover POSTs to this URL once the notification is acknowledged.', + required: false, + }), + attachment_base64: Property.LongText({ + displayName: 'Attachment (Base64)', + description: + 'Base64-encoded image to attach, maximum 5242880 bytes decoded. Requires Attachment Type.', + required: false, + }), + attachment_type: Property.ShortText({ + displayName: 'Attachment Type', + description: + "MIME type of the attachment, for example 'image/jpeg'. Required when an attachment is supplied.", + required: false, + }), + }, + outputSchema: sendPushMessageOutputSchema, + async run({ auth, propsValue }) { + const body = buildMessageBody({ + apiToken: auth.props.api_token, + userKey: auth.props.user_key, + message: propsValue.message, + title: propsValue.title, + html: propsValue.html, + monospace: propsValue.monospace, + priority: propsValue.priority, + retry: propsValue.retry, + expire: propsValue.expire, + ttl: propsValue.ttl, + url: propsValue.url, + urlTitle: propsValue.url_title, + timestamp: propsValue.timestamp, + device: propsValue.device, + sound: propsValue.sound, + tags: propsValue.tags, + callback: propsValue.callback, + attachmentBase64: propsValue.attachment_base64, + attachmentType: propsValue.attachment_type, + }); + + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: '/messages.json', + body, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/actions/update-glance.ts b/packages/pieces/community/pushover/src/lib/actions/update-glance.ts new file mode 100644 index 000000000000..f1cddf4c1e4b --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/update-glance.ts @@ -0,0 +1,89 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; + +export const updateGlance = createAction({ + auth: pushoverAuth, + name: 'update_glance', + classification: 'WRITE', + displayName: 'Update Glance Widget', + description: 'Update the Pushover glance widget fields on the user devices', + audience: 'ai', + aiMetadata: { + description: + 'Silently update the Pushover glance widget on the recipient devices without sending a notification. Use it for a continuously refreshed status number or line of text, and use Send Push Message when the person should actually be alerted. At least one of title, text, subtext, count or percent is required. Fields persist between calls: omit a field to leave its current value alone. This action only sets field values and cannot clear a field once it has one. A registered widget is required and the Apple Watch is the only supported widget, so a success response on an account without one proves the call shape only. Safe to retry: the same values converge on the same widget state.', + idempotent: true, + }, + props: { + title: Property.ShortText({ + displayName: 'Title', + description: + 'Short description of the data, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.', + required: false, + }), + text: Property.ShortText({ + displayName: 'Text', + description: + 'Main line of text, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.', + required: false, + }), + subtext: Property.ShortText({ + displayName: 'Subtext', + description: + 'Second line of text, maximum 100 characters. Omit to leave unchanged. Values cannot be cleared once set.', + required: false, + }), + count: Property.Number({ + displayName: 'Count', + description: + 'Integer shown on the widget, may be negative. Omit to leave unchanged.', + required: false, + }), + percent: Property.Number({ + displayName: 'Percent', + description: + 'Integer from 0 to 100 rendered as a progress bar. Omit to leave unchanged.', + required: false, + }), + device: Property.ShortText({ + displayName: 'Device', + description: + 'Update only this device name. All devices are updated when omitted.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const fields = { + ...(isFilled(propsValue.title) ? { title: propsValue.title } : {}), + ...(isFilled(propsValue.text) ? { text: propsValue.text } : {}), + ...(isFilled(propsValue.subtext) + ? { subtext: propsValue.subtext } + : {}), + ...(propsValue.count !== undefined ? { count: propsValue.count } : {}), + ...(propsValue.percent !== undefined + ? { percent: propsValue.percent } + : {}), + }; + if (Object.keys(fields).length === 0) { + throw new Error( + 'Update Glance Widget needs at least one of title, text, subtext, count or percent.' + ); + } + + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: '/glances.json', + body: { + token: auth.props.api_token, + user: auth.props.user_key, + ...fields, + ...(propsValue.device ? { device: propsValue.device } : {}), + }, + }); + }, +}); + +function isFilled(value: string | undefined): value is string { + return value !== undefined && value.length > 0; +} diff --git a/packages/pieces/community/pushover/src/lib/actions/validate-user-key.ts b/packages/pieces/community/pushover/src/lib/actions/validate-user-key.ts new file mode 100644 index 000000000000..e7f4cdf77a80 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/actions/validate-user-key.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pushoverAuth } from '../..'; +import { pushoverApiCall } from '../common'; +import { validateUserKeyOutputSchema } from '../output-schemas'; + +export const validateUserKey = createAction({ + auth: pushoverAuth, + name: 'validate_user_key', + classification: 'READ', + displayName: 'Validate User or Group Key', + description: 'Check whether a Pushover user or group key is valid and reachable', + audience: 'ai', + aiMetadata: { + description: + 'Check before sending whether a user or group key exists, is not disabled, and has at least one active device, returning its device names and licenses. Use it to validate a key handed to you by someone else; leave the key blank to check the key stored on the connection. Read-only despite being a POST: nothing is delivered and nothing changes. Safe to retry.', + idempotent: true, + }, + props: { + user: Property.ShortText({ + displayName: 'User or Group Key', + description: + 'The 30-character user or group key to validate. Defaults to the key stored on the connection when left blank.', + required: false, + }), + device: Property.ShortText({ + displayName: 'Device', + description: + 'Optional device name to validate alongside the key, for example iphone.', + required: false, + }), + }, + outputSchema: validateUserKeyOutputSchema, + async run({ auth, propsValue }) { + const suppliedUser = propsValue.user; + const user = + suppliedUser !== undefined && suppliedUser.length > 0 + ? suppliedUser + : auth.props.user_key; + + return await pushoverApiCall({ + method: HttpMethod.POST, + resourceUri: '/users/validate.json', + body: { + token: auth.props.api_token, + user, + ...(propsValue.device ? { device: propsValue.device } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/pushover/src/lib/common/index.ts b/packages/pieces/community/pushover/src/lib/common/index.ts new file mode 100644 index 000000000000..5a5927a82882 --- /dev/null +++ b/packages/pieces/community/pushover/src/lib/common/index.ts @@ -0,0 +1,331 @@ +import { + HttpError, + HttpMessageBody, + HttpMethod, + httpClient, +} from '@activepieces/pieces-common'; + +export const PUSHOVER_BASE_URL = 'https://api.pushover.net/1'; + +export const PUSHOVER_MAX_EMERGENCY_RETRIES = 50; +export const PUSHOVER_MIN_RETRY_SECONDS = 30; +export const PUSHOVER_MAX_EXPIRE_SECONDS = 10800; +export const PUSHOVER_MIN_PRIORITY = -2; +export const PUSHOVER_MAX_PRIORITY = 2; +export const PUSHOVER_MAX_ATTACHMENT_BYTES = 5242880; + +const MAX_ERROR_DETAIL_LENGTH = 400; + +const ERROR_BODY_METADATA_KEYS = new Set(['status', 'request', 'receipt']); + +const DATA_URI_PREFIX_PATTERN = /^data:[^;,]*;base64,/; + +const WHITESPACE_PATTERN = /\s+/g; + +export async function pushoverApiCall({ + method, + resourceUri, + body, + queryParams, + errorHint, +}: PushoverApiCallParams): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${PUSHOVER_BASE_URL}${resourceUri}`, + body, + queryParams, + }); + return response.body; + } catch (error) { + throw new PushoverRequestError({ + message: describePushoverError({ error, errorHint }), + status: error instanceof HttpError ? error.response.status : undefined, + vendorErrors: + error instanceof HttpError + ? collectVendorErrors(error.response.body) + : [], + responseBody: + error instanceof HttpError ? error.response.body : undefined, + }); + } +} + +export function buildMessageBody({ + apiToken, + userKey, + title, + message, + html, + monospace, + priority, + retry, + expire, + ttl, + url, + urlTitle, + timestamp, + device, + sound, + tags, + callback, + attachmentBase64, + attachmentType, +}: BuildMessageBodyParams): Record { + if (html === true && monospace === true) { + throw new Error( + 'Pushover rejects html and monospace together: choose one message formatting mode.' + ); + } + const priorityNumber = isPresent(priority) ? Number(priority) : undefined; + if (priorityNumber !== undefined && Number.isNaN(priorityNumber)) { + throw new Error( + `Priority must be a number between ${PUSHOVER_MIN_PRIORITY} and ${PUSHOVER_MAX_PRIORITY}.` + ); + } + if ( + priorityNumber !== undefined && + (priorityNumber < PUSHOVER_MIN_PRIORITY || + priorityNumber > PUSHOVER_MAX_PRIORITY) + ) { + throw new Error( + `Priority must be a number between ${PUSHOVER_MIN_PRIORITY} and ${PUSHOVER_MAX_PRIORITY}.` + ); + } + if (priorityNumber === 2) { + if (!isPresent(retry) || !isPresent(expire)) { + throw new Error( + `Emergency priority (2) requires both retry (>= ${PUSHOVER_MIN_RETRY_SECONDS} seconds) and expire (<= ${PUSHOVER_MAX_EXPIRE_SECONDS} seconds).` + ); + } + if (retry < PUSHOVER_MIN_RETRY_SECONDS) { + throw new Error( + `Emergency priority (2) requires retry to be at least ${PUSHOVER_MIN_RETRY_SECONDS} seconds.` + ); + } + if (expire < 1 || expire > PUSHOVER_MAX_EXPIRE_SECONDS) { + throw new Error( + `Emergency priority (2) requires expire between 1 and ${PUSHOVER_MAX_EXPIRE_SECONDS} seconds.` + ); + } + } + if (isPresent(attachmentBase64) && !isPresent(attachmentType)) { + throw new Error( + 'attachment_type (for example image/jpeg) is required whenever an attachment is supplied.' + ); + } + if ( + isPresent(attachmentBase64) && + decodedBase64ByteLength(attachmentBase64) > PUSHOVER_MAX_ATTACHMENT_BYTES + ) { + throw new Error( + `The attachment decodes to more than the Pushover limit of ${PUSHOVER_MAX_ATTACHMENT_BYTES} bytes (5 MB). Resize or recompress the image before sending.` + ); + } + + return { + token: apiToken, + user: userKey, + message, + ...spreadWhenPresent({ title }), + ...spreadWhenPresent({ url }), + ...spreadWhenPresent({ url_title: urlTitle }), + ...spreadWhenPresent({ timestamp }), + ...spreadWhenPresent({ device }), + ...spreadWhenPresent({ callback }), + ...spreadWhenPresent({ tags }), + ...spreadWhenPresent({ sound }), + ...spreadWhenPresent({ attachment_base64: attachmentBase64 }), + ...spreadWhenPresent({ attachment_type: attachmentType }), + ...(html === true ? { html: 1 } : {}), + ...(monospace === true ? { monospace: 1 } : {}), + ...(priorityNumber !== undefined ? { priority: priorityNumber } : {}), + ...(priorityNumber === 2 ? { retry, expire } : {}), + ...(priorityNumber !== 2 && isPresent(ttl) ? { ttl } : {}), + }; +} + +function decodedBase64ByteLength(attachmentBase64: string): number { + const payload = attachmentBase64 + .replace(DATA_URI_PREFIX_PATTERN, '') + .replace(WHITESPACE_PATTERN, ''); + const paddingLength = payload.endsWith('==') + ? 2 + : payload.endsWith('=') + ? 1 + : 0; + return Math.floor((payload.length * 3) / 4) - paddingLength; +} + +function isPresent(value: T | undefined | null): value is T { + if (value === undefined || value === null) { + return false; + } + return typeof value === 'string' ? value.length > 0 : true; +} + +function spreadWhenPresent( + entry: Record +): Record { + return Object.fromEntries( + Object.entries(entry).filter(([, value]) => isPresent(value)) + ); +} + +function truncateDetail(detail: string): string { + return detail.length > MAX_ERROR_DETAIL_LENGTH + ? `${detail.slice(0, MAX_ERROR_DETAIL_LENGTH)}...` + : detail; +} + +function flattenErrorValue(value: unknown): string[] { + if (typeof value === 'string') { + return value.trim().length > 0 ? [value.trim()] : []; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return [String(value)]; + } + if (Array.isArray(value)) { + return value.flatMap(flattenErrorValue); + } + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, nested]) => + flattenErrorValue(nested).map((entry) => `${key}: ${entry}`) + ); + } + return []; +} + +function collectVendorErrors(body: unknown): string[] { + if (typeof body === 'string') { + const trimmed = body.trim(); + return trimmed.length > 0 ? [trimmed] : []; + } + if (typeof body !== 'object' || body === null) { + return []; + } + const listedErrors = flattenErrorValue( + 'errors' in body ? body.errors : undefined + ); + const fieldErrors = Object.entries(body) + .filter(([key]) => key !== 'errors' && !ERROR_BODY_METADATA_KEYS.has(key)) + .flatMap(([key, value]) => + flattenErrorValue(value).map((entry) => `${key}: ${entry}`) + ); + return [...listedErrors, ...fieldErrors]; +} + +function readVendorErrors(body: unknown): string { + if (typeof body === 'string') { + const trimmed = body.trim(); + return trimmed.length > 0 + ? ` Pushover said: ${truncateDetail(trimmed)}.` + : ''; + } + if (typeof body !== 'object' || body === null) { + return ''; + } + + const listedErrors = flattenErrorValue( + 'errors' in body ? body.errors : undefined + ); + const fieldErrors = Object.entries(body) + .filter(([key]) => key !== 'errors' && !ERROR_BODY_METADATA_KEYS.has(key)) + .flatMap(([key, value]) => + typeof value === 'string' && value.trim().length > 0 + ? [`${key}: ${value.trim()}`] + : [] + ); + const messages = [...listedErrors, ...fieldErrors]; + if (messages.length > 0) { + return ` Pushover said: ${truncateDetail(messages.join('; '))}.`; + } + + return ` Pushover returned this body: ${truncateDetail(JSON.stringify(body))}.`; +} + +function describePushoverError({ + error, + errorHint, +}: DescribeErrorParams): string { + const hint = isPresent(errorHint) ? ` ${errorHint}` : ''; + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : String(error); + } + const status = error.response.status; + const vendorErrors = readVendorErrors(error.response.body); + if (status === 429) { + return `Pushover returned 429: the application monthly message quota is exhausted, not a transient rate limit. Check Get Monthly Message Limits for the reset date.${vendorErrors}`; + } + if (status >= 500) { + return `Pushover returned ${status}. This class is retryable, but wait at least 5 seconds before trying again.${vendorErrors}`; + } + if (status >= 400) { + return `Pushover returned ${status}. 4xx responses are terminal: retrying the same request will fail again, and repeated bad requests can get the IP temporarily blocked. Fix the input first.${hint}${vendorErrors}`; + } + return `Pushover request failed with status ${status}.${vendorErrors}`; +} + +export class PushoverRequestError extends Error { + readonly status: number | undefined; + readonly vendorErrors: string[]; + readonly responseBody: unknown; + + constructor({ + message, + status, + vendorErrors, + responseBody, + }: PushoverRequestErrorParams) { + super(message); + this.name = 'PushoverRequestError'; + this.status = status; + this.vendorErrors = vendorErrors; + this.responseBody = responseBody; + } +} + +export const GROUP_OWNERSHIP_HINT = + 'The Groups API requires the application token to belong to the same account that owns the group, so an ownership mismatch reports the same way as an unknown group key.'; + +export type PushoverApiCallParams = { + method: HttpMethod; + resourceUri: string; + body?: Record; + queryParams?: Record; + errorHint?: string; +}; + +export type BuildMessageBodyParams = { + apiToken: string; + userKey: string; + message: string; + title?: string; + html?: boolean; + monospace?: boolean; + priority?: number | string; + retry?: number; + expire?: number; + ttl?: number; + url?: string; + urlTitle?: string; + timestamp?: string; + device?: string; + sound?: string; + tags?: string; + callback?: string; + attachmentBase64?: string; + attachmentType?: string; +}; + +export type DescribeErrorParams = { + error: unknown; + errorHint?: string; +}; + +export type PushoverRequestErrorParams = { + message: string; + status: number | undefined; + vendorErrors: string[]; + responseBody: unknown; +}; diff --git a/packages/pieces/community/pushover/src/lib/output-schemas.ts b/packages/pieces/community/pushover/src/lib/output-schemas.ts index 742de019e784..0f1c479a6e64 100644 --- a/packages/pieces/community/pushover/src/lib/output-schemas.ts +++ b/packages/pieces/community/pushover/src/lib/output-schemas.ts @@ -24,3 +24,470 @@ export const sendNotificationActionOutputSchema: OutputSchema = { }, ], }; + +export const sendPushMessageOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the notification. Any other value means it was rejected.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request, worth quoting when reporting a delivery problem.', + }, + { + key: 'receipt', + label: 'Receipt', + description: + 'Only returned at emergency priority (2). Pass it to Get Emergency Receipt Status or Cancel Emergency Retries.', + }, + ], +}; + +export const getAppLimitsOutputSchema: OutputSchema = { + fields: [ + { + key: 'limit', + label: 'Monthly Limit', + format: 'number', + description: 'Total messages the application may send this month.', + }, + { + key: 'remaining', + label: 'Remaining', + format: 'number', + description: + 'Messages still available before the monthly quota is exhausted and sends start returning 429.', + }, + { + key: 'reset', + label: 'Quota Reset', + format: 'number', + description: 'Unix timestamp in seconds at which the monthly counter resets.', + }, + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request, worth quoting when reporting a problem.', + }, + ], +}; + +export const listNotificationSoundsOutputSchema: OutputSchema = { + fields: [ + { + key: 'sounds', + label: 'Sounds', + labelKey: 'name', + description: 'Every sound Pushover accepts for the Sound parameter of Send Push Message.', + listItems: [ + { + key: 'id', + label: 'Sound ID', + description: 'Identifier to pass as the Sound parameter, for example pushover or cosmic.', + }, + { + key: 'name', + label: 'Name', + description: 'Human-readable sound name, for example Pushover (default).', + }, + ], + }, + { + key: 'count', + label: 'Count', + format: 'number', + description: 'Number of sounds returned.', + }, + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const listDeliveryGroupsOutputSchema: OutputSchema = { + fields: [ + { + key: 'groups', + label: 'Groups', + labelKey: 'name', + description: 'Delivery groups owned by the same account as the application token.', + listItems: [ + { + key: 'group', + label: 'Group Key', + description: 'The 30-character group key every other group action takes as input.', + }, + { + key: 'name', + label: 'Name', + description: 'Group name, unique within the account.', + }, + ], + }, + { + key: 'count', + label: 'Count', + format: 'number', + description: 'Number of groups returned.', + }, + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const getDeliveryGroupOutputSchema: OutputSchema = { + fields: [ + { + key: 'name', + label: 'Name', + description: 'Name of the delivery group.', + }, + { + key: 'users', + label: 'Members', + labelKey: 'user', + description: 'Every member of the group, including members currently suspended.', + listItems: [ + { + key: 'user', + label: 'User Key', + description: + 'The 30-character user key of the member, as passed to Remove User from Delivery Group, Disable Group User and Enable Group User.', + }, + { + key: 'device', + label: 'Device', + description: + 'Device the group delivers to for this member, or null when every device of theirs receives group messages.', + }, + { + key: 'memo', + label: 'Memo', + description: 'Note stored with the membership, for example Primary on-call.', + }, + { + key: 'disabled', + label: 'Disabled', + format: 'boolean', + description: + 'True while the member is suspended by Disable Group User and group messages skip them. Restore with Enable Group User.', + }, + { + key: 'valid', + label: 'Valid', + format: 'boolean', + description: + 'False when the user key is no longer reachable, for example because the account was deleted or has no active device.', + }, + ], + }, + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const getLicenseCreditsOutputSchema: OutputSchema = { + fields: [ + { + key: 'credits', + label: 'Credits', + format: 'number', + description: + 'Prepaid license credits the application still holds. Assign License spends one irreversibly.', + }, + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const validateUserKeyOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when the key is valid, has at least one active device and is not disabled.', + }, + { + key: 'group', + label: 'Is Group', + format: 'number', + description: '1 when the validated key is a delivery group key, 0 when it is a user key.', + }, + { + key: 'devices', + label: 'Devices', + description: + 'Names of the devices registered to this user, usable as the Device parameter of Send Push Message.', + }, + { + key: 'licenses', + label: 'Licenses', + description: 'Platforms the user holds a license for, for example Android or iOS.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const createDeliveryGroupOutputSchema: OutputSchema = { + fields: [ + { + key: 'group', + label: 'Group Key', + description: 'The 30-character group key, whether the group was created or already existed.', + }, + { + key: 'name', + label: 'Name', + description: 'Name of the group.', + }, + { + key: 'created', + label: 'Created', + format: 'boolean', + description: + 'True when a new group was created, false when an existing group with that name was returned.', + }, + ], +}; + +export const renameDeliveryGroupOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover renamed the group.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const getReceiptStatusOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'acknowledged', + label: 'Acknowledged', + format: 'number', + description: + '1 once a recipient acknowledged the emergency notification, which stops the retries.', + }, + { + key: 'acknowledged_at', + label: 'Acknowledged At', + format: 'number', + description: 'Unix timestamp in seconds of the acknowledgement, 0 while unacknowledged.', + }, + { + key: 'acknowledged_by', + label: 'Acknowledged By', + description: 'User key of the recipient who acknowledged, empty while unacknowledged.', + }, + { + key: 'acknowledged_by_device', + label: 'Acknowledged By Device', + description: 'Device name that acknowledged, empty while unacknowledged.', + }, + { + key: 'last_delivered_at', + label: 'Last Delivered At', + format: 'number', + description: 'Unix timestamp in seconds of the most recent retry delivery.', + }, + { + key: 'expired', + label: 'Expired', + format: 'number', + description: '1 once the retry window closed without an acknowledgement.', + }, + { + key: 'expires_at', + label: 'Expires At', + format: 'number', + description: 'Unix timestamp in seconds at which retrying stops.', + }, + { + key: 'called_back', + label: 'Called Back', + format: 'number', + description: '1 once Pushover called the callback URL supplied at send time.', + }, + { + key: 'called_back_at', + label: 'Called Back At', + format: 'number', + description: 'Unix timestamp in seconds of the callback, 0 when no callback fired.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const cancelEmergencyRetriesOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when the retry loop for this receipt is stopped.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const cancelEmergencyRetriesByTagOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: '1 when Pushover accepted the request.', + }, + { + key: 'canceled', + label: 'Canceled Count', + format: 'number', + description: + 'Number of still-retrying emergency notifications carrying the tag that were stopped.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const addUserToGroupOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: + '1 when the user was added as a member. Adding someone who is already a member fails instead of returning a non-1 status.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const removeUserFromGroupOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: + '1 when the member was removed. Removing someone who is not a member fails instead of returning a non-1 status.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const disableGroupUserOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: + '1 when the member is suspended. Read the membership back with Get Delivery Group to see disabled turn true.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; + +export const enableGroupUserOutputSchema: OutputSchema = { + fields: [ + { + key: 'status', + label: 'Status', + format: 'number', + description: + '1 when the member receives group messages again. Read the membership back with Get Delivery Group to see disabled turn false.', + }, + { + key: 'request', + label: 'Request ID', + description: 'Pushover\'s identifier for this request.', + }, + ], +}; From 66b00d8d26ced8f1438b7926ef1dbad249cb72d4 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Tue, 22 Sep 2026 14:14:39 +0300 Subject: [PATCH 3/7] feat(google-contacts): let agents manage contacts, groups and photos (#15695) --- .../community/google-contacts/package.json | 2 +- .../google-contacts/src/i18n/translation.json | 91 ++++- .../community/google-contacts/src/index.ts | 46 ++- .../src/lib/action/batch-create-contacts.ts | 134 +++++++ .../src/lib/action/batch-delete-contacts.ts | 53 +++ .../lib/action/batch-get-contact-groups.ts | 89 +++++ .../src/lib/action/batch-get-contacts.ts | 83 +++++ .../src/lib/action/batch-update-contacts.ts | 167 +++++++++ .../src/lib/action/create-contact-agent.ts | 105 ++++++ .../src/lib/action/create-contact-group.ts | 120 +++++++ .../src/lib/action/create-contact.ts | 2 +- .../src/lib/action/delete-contact-group.ts | 53 +++ .../src/lib/action/delete-contact-photo.ts | 57 +++ .../src/lib/action/delete-contact.ts | 41 +++ .../src/lib/action/get-contact-group.ts | 56 +++ .../src/lib/action/get-contact.ts | 43 +++ .../src/lib/action/list-contact-groups.ts | 66 ++++ .../src/lib/action/list-contacts.ts | 73 ++++ .../action/modify-contact-group-members.ts | 102 ++++++ .../src/lib/action/search-contact.ts | 2 +- .../src/lib/action/search-contacts.ts | 73 ++++ .../src/lib/action/update-contact-fields.ts | 163 +++++++++ .../src/lib/action/update-contact-group.ts | 79 ++++ .../src/lib/action/update-contact-photo.ts | 75 ++++ .../src/lib/action/update-contact.ts | 2 +- .../src/lib/common/contact-group.ts | 43 +++ .../google-contacts/src/lib/common/index.ts | 340 ++++++++++++++++++ .../google-contacts/src/lib/common/person.ts | 157 ++++++++ .../google-contacts/src/lib/output-schemas.ts | 272 +++++++++++++- 29 files changed, 2555 insertions(+), 34 deletions(-) create mode 100644 packages/pieces/community/google-contacts/src/lib/action/batch-create-contacts.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/batch-delete-contacts.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/batch-get-contact-groups.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/batch-get-contacts.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/batch-update-contacts.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/create-contact-agent.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/create-contact-group.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/delete-contact-group.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/delete-contact-photo.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/delete-contact.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/get-contact-group.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/get-contact.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/list-contact-groups.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/list-contacts.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/modify-contact-group-members.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/search-contacts.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/update-contact-fields.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/update-contact-group.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/action/update-contact-photo.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/common/contact-group.ts create mode 100644 packages/pieces/community/google-contacts/src/lib/common/person.ts diff --git a/packages/pieces/community/google-contacts/package.json b/packages/pieces/community/google-contacts/package.json index 2f44aee72074..e5276ee66fb3 100644 --- a/packages/pieces/community/google-contacts/package.json +++ b/packages/pieces/community/google-contacts/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-google-contacts", - "version": "0.5.0", + "version": "0.5.1", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/google-contacts/src/i18n/translation.json b/packages/pieces/community/google-contacts/src/i18n/translation.json index 43bc7d899141..bb6f78ba0988 100644 --- a/packages/pieces/community/google-contacts/src/i18n/translation.json +++ b/packages/pieces/community/google-contacts/src/i18n/translation.json @@ -3,10 +3,47 @@ "Add Contact": "Add Contact", "Update Contact": "Update Contact", "Search Contacts": "Search Contacts", + "Create Contact": "Create Contact", + "Update Contact Fields": "Update Contact Fields", + "Get Contact": "Get Contact", + "List Contacts": "List Contacts", + "Delete Contact": "Delete Contact", + "Batch Get Contacts": "Batch Get Contacts", + "Batch Create Contacts": "Batch Create Contacts", + "Batch Update Contacts": "Batch Update Contacts", + "Batch Delete Contacts": "Batch Delete Contacts", + "Update Contact Photo": "Update Contact Photo", + "Delete Contact Photo": "Delete Contact Photo", + "List Contact Groups": "List Contact Groups", + "Get Contact Group": "Get Contact Group", + "Batch Get Contact Groups": "Batch Get Contact Groups", + "Create Contact Group": "Create Contact Group", + "Update Contact Group": "Update Contact Group", + "Delete Contact Group": "Delete Contact Group", + "Add Or Remove Contact Group Members": "Add Or Remove Contact Group Members", "Custom API Call": "Custom API Call", "Add a contact to a Google Contacts account": "Add a contact to a Google Contacts account", "Update a contact in Google Contacts account.": "Update a contact in Google Contacts account.", "Search contacts in Google Contacts account.": "Search contacts in Google Contacts account.", + "Create a new contact in the connected Google account.": "Create a new contact in the connected Google account.", + "Update only the supplied fields on an existing contact.": "Update only the supplied fields on an existing contact.", + "Search the connected account contacts by a plain-text query.": "Search the connected account contacts by a plain-text query.", + "Read a single contact by its resource name.": "Read a single contact by its resource name.", + "List the contacts of the connected Google account.": "List the contacts of the connected Google account.", + "Permanently delete a contact by its resource name.": "Permanently delete a contact by its resource name.", + "Read up to 200 contacts in one call by their resource names.": "Read up to 200 contacts in one call by their resource names.", + "Create up to 200 contacts in a single call.": "Create up to 200 contacts in a single call.", + "Update up to 200 contacts in a single call from full Person objects.": "Update up to 200 contacts in a single call from full Person objects.", + "Permanently delete up to 500 contacts in a single call.": "Permanently delete up to 500 contacts in a single call.", + "Set the photo of an existing contact from base64 image bytes.": "Set the photo of an existing contact from base64 image bytes.", + "Remove the photo of an existing contact.": "Remove the photo of an existing contact.", + "List every contact group of the connected Google account.": "List every contact group of the connected Google account.", + "Read one contact group, optionally with its member resource names.": "Read one contact group, optionally with its member resource names.", + "Read up to 200 contact groups in one call by their resource names.": "Read up to 200 contact groups in one call by their resource names.", + "Create a contact group, or return the existing one with that name.": "Create a contact group, or return the existing one with that name.", + "Rename an existing user-created contact group.": "Rename an existing user-created contact group.", + "Delete a user-created contact group.": "Delete a user-created contact group.", + "Add contacts to a contact group or remove them from it.": "Add contacts to a contact group or remove them from it.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "First Name": "First Name", "Middle Name": "Middle Name", @@ -21,14 +58,29 @@ "Query": "Query", "Read Mask": "Read Mask", "Page Size": "Page Size", + "Nickname": "Nickname", + "Email Addresses": "Email Addresses", + "Phone Numbers": "Phone Numbers", + "Notes": "Notes", + "Max Results": "Max Results", + "Resource Names": "Resource Names", + "Contacts": "Contacts", + "Photo Bytes": "Photo Bytes", + "Max Members": "Max Members", + "Group Name": "Group Name", + "New Group Name": "New Group Name", + "Also Delete Contacts": "Also Delete Contacts", + "Contact Group Resource Name": "Contact Group Resource Name", + "Contacts To Add": "Contacts To Add", + "Contacts To Remove": "Contacts To Remove", "Method": "Method", "Headers": "Headers", "Query Parameters": "Query Parameters", "Body Type": "Body Type", "Body": "Body", - "Response is Binary ?": "Response is Binary ?", - "No Error on Failure": "No Error on Failure", - "Timeout (in seconds)": "Timeout (in seconds)", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", "Follow redirects": "Follow redirects", "The first name of the contact": "The first name of the contact", "The middle name of the contact": "The middle name of the contact", @@ -43,8 +95,37 @@ "The plain-text query for the request.The query is used to match prefix phrases of the fields on a person. For example, a person with name \"foo name\" matches queries such as \"f\", \"fo\", \"foo\", \"foo n\", \"nam\", etc., but not \"oo n\".": "The plain-text query for the request.The query is used to match prefix phrases of the fields on a person. For example, a person with name \"foo name\" matches queries such as \"f\", \"fo\", \"foo\", \"foo n\", \"nam\", etc., but not \"oo n\".", "A field mask to restrict which fields on each person are returned.": "A field mask to restrict which fields on each person are returned.", "The number of results to return. Maximum 30.": "The number of results to return. Maximum 30.", + "Array of email address strings, for example [\"ada@example.com\"]. A single address still has to be wrapped in an array.": "Array of email address strings, for example [\"ada@example.com\"]. A single address still has to be wrapped in an array.", + "Array of phone number strings, for example [\"+1 555 0100\"]. A single number still has to be wrapped in an array.": "Array of phone number strings, for example [\"+1 555 0100\"]. A single number still has to be wrapped in an array.", + "Free-text notes stored on the contact biography.": "Free-text notes stored on the contact biography.", + "Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts.": "Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts.", + "Array of email address strings, for example [\"ada@example.com\"]. Replaces every email address stored on the contact. Leave empty to keep the stored addresses.": "Array of email address strings, for example [\"ada@example.com\"]. Replaces every email address stored on the contact. Leave empty to keep the stored addresses.", + "Array of phone number strings, for example [\"+1 555 0100\"]. Replaces every phone number stored on the contact. Leave empty to keep the stored numbers.": "Array of phone number strings, for example [\"+1 555 0100\"]. Replaces every phone number stored on the contact. Leave empty to keep the stored numbers.", + "Plain-text query prefix-matched against contact fields such as name, email and phone number.": "Plain-text query prefix-matched against contact fields such as name, email and phone number.", + "Number of results to return, maximum 30. Defaults to 30.": "Number of results to return, maximum 30. Defaults to 30.", + "Maximum number of contacts to return. Defaults to 200.": "Maximum number of contacts to return. Defaults to 200.", + "Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts. Deletion cannot be undone.": "Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts. Deletion cannot be undone.", + "Up to 200 opaque contact resource names such as people/c12345, resolved with List Contacts or Search Contacts.": "Up to 200 opaque contact resource names such as people/c12345, resolved with List Contacts or Search Contacts.", + "Up to 200 contacts to create. Each needs at least a name, an email or a phone number.": "Up to 200 contacts to create. Each needs at least a name, an email or a phone number.", + "JSON array of up to 200 full Person objects taken from Batch Get Contacts, each keeping its resourceName and etag, with the fields to change edited in place.": "JSON array of up to 200 full Person objects taken from Batch Get Contacts, each keeping its resourceName and etag, with the fields to change edited in place.", + "Up to 500 opaque contact resource names such as people/c12345. Every listed contact is deleted permanently and cannot be restored.": "Up to 500 opaque contact resource names such as people/c12345. Every listed contact is deleted permanently and cannot be restored.", + "Base64-encoded JPEG, PNG or WebP image. A data URL prefix is accepted and stripped.": "Base64-encoded JPEG, PNG or WebP image. A data URL prefix is accepted and stripped.", + "Opaque contact group resource name such as contactGroups/abc123, or a system group like contactGroups/myContacts. Resolve it with List Contact Groups.": "Opaque contact group resource name such as contactGroups/abc123, or a system group like contactGroups/myContacts. Resolve it with List Contact Groups.", + "How many member resource names to return. Defaults to 0, which returns none.": "How many member resource names to return. Defaults to 0, which returns none.", + "Up to 200 opaque contact group resource names such as contactGroups/abc123, resolved with List Contact Groups.": "Up to 200 opaque contact group resource names such as contactGroups/abc123, resolved with List Contact Groups.", + "How many member resource names to return per group. Defaults to 0, which returns none.": "How many member resource names to return per group. Defaults to 0, which returns none.", + "Name of the contact group to find or create.": "Name of the contact group to find or create.", + "Opaque contact group resource name such as contactGroups/abc123. Resolve it with List Contact Groups.": "Opaque contact group resource name such as contactGroups/abc123. Resolve it with List Contact Groups.", + "When enabled, every contact in the group is permanently deleted as well. Leave off to remove only the group.": "When enabled, every contact in the group is permanently deleted as well. Leave off to remove only the group.", + "Opaque contact group resource name such as contactGroups/abc123, contactGroups/myContacts or contactGroups/starred. Resolve it with List Contact Groups.": "Opaque contact group resource name such as contactGroups/abc123, contactGroups/myContacts or contactGroups/starred. Resolve it with List Contact Groups.", + "Opaque contact resource names such as people/c12345 to add to the group.": "Opaque contact resource names such as people/c12345 to add to the group.", + "Opaque contact resource names such as people/c12345 to remove from the group.": "Opaque contact resource names such as people/c12345 to remove from the group.", "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", + "Appended to the URL as ?key=value.": "Appended to the URL as ?key=value.", "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", + "On a failed request, output the error instead of failing the step.": "On a failed request, output the error instead of failing the step.", + "Seconds to wait for a response. Empty: up to the flow limit (10 min).": "Seconds to wait for a response. Empty: up to the flow limit (10 min).", + "Follow 3xx redirects instead of returning them as the response.": "Follow 3xx redirects instead of returning them as the response.", "Names": "Names", "Job Title / Company": "Job Title / Company", "addresses": "addresses", @@ -82,10 +163,6 @@ "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw", "New Or Updated Contact": "New Or Updated Contact", "Triggers when there is a new or updated contact": "Triggers when there is a new or updated contact" } \ No newline at end of file diff --git a/packages/pieces/community/google-contacts/src/index.ts b/packages/pieces/community/google-contacts/src/index.ts index b09affdb2cb0..67456a968b1c 100644 --- a/packages/pieces/community/google-contacts/src/index.ts +++ b/packages/pieces/community/google-contacts/src/index.ts @@ -1,25 +1,59 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { - OAuth2PropertyValue, - PieceAuth, - createPiece, -} from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { googleContactsAddContactAction } from './lib/action/create-contact'; import { googleContactsUpdateContactAction } from './lib/action/update-contact'; import { googleContactsSearchContactsAction } from './lib/action/search-contact'; +import { googleContactsCreateContactAction } from './lib/action/create-contact-agent'; +import { googleContactsUpdateContactFieldsAction } from './lib/action/update-contact-fields'; +import { googleContactsSearchContactsAtomicAction } from './lib/action/search-contacts'; +import { googleContactsGetContactAction } from './lib/action/get-contact'; +import { googleContactsListContactsAction } from './lib/action/list-contacts'; +import { googleContactsDeleteContactAction } from './lib/action/delete-contact'; +import { googleContactsBatchGetContactsAction } from './lib/action/batch-get-contacts'; +import { googleContactsBatchCreateContactsAction } from './lib/action/batch-create-contacts'; +import { googleContactsBatchUpdateContactsAction } from './lib/action/batch-update-contacts'; +import { googleContactsBatchDeleteContactsAction } from './lib/action/batch-delete-contacts'; +import { googleContactsUpdateContactPhotoAction } from './lib/action/update-contact-photo'; +import { googleContactsDeleteContactPhotoAction } from './lib/action/delete-contact-photo'; +import { googleContactsListContactGroupsAction } from './lib/action/list-contact-groups'; +import { googleContactsGetContactGroupAction } from './lib/action/get-contact-group'; +import { googleContactsBatchGetContactGroupsAction } from './lib/action/batch-get-contact-groups'; +import { googleContactsCreateContactGroupAction } from './lib/action/create-contact-group'; +import { googleContactsUpdateContactGroupAction } from './lib/action/update-contact-group'; +import { googleContactsDeleteContactGroupAction } from './lib/action/delete-contact-group'; +import { googleContactsModifyContactGroupMembersAction } from './lib/action/modify-contact-group-members'; import { googleContactsCommon } from './lib/common'; import { googleContactNewOrUpdatedContact } from './lib/trigger/new-contact'; import { googleContactsAuth } from './lib/auth'; export const googleContacts = createPiece({ - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/google-contacts.png', categories: [PieceCategory.SALES_AND_CRM], actions: [ googleContactsAddContactAction, googleContactsUpdateContactAction, googleContactsSearchContactsAction, + googleContactsCreateContactAction, + googleContactsUpdateContactFieldsAction, + googleContactsSearchContactsAtomicAction, + googleContactsGetContactAction, + googleContactsListContactsAction, + googleContactsDeleteContactAction, + googleContactsBatchGetContactsAction, + googleContactsBatchCreateContactsAction, + googleContactsBatchUpdateContactsAction, + googleContactsBatchDeleteContactsAction, + googleContactsUpdateContactPhotoAction, + googleContactsDeleteContactPhotoAction, + googleContactsListContactGroupsAction, + googleContactsGetContactGroupAction, + googleContactsBatchGetContactGroupsAction, + googleContactsCreateContactGroupAction, + googleContactsUpdateContactGroupAction, + googleContactsDeleteContactGroupAction, + googleContactsModifyContactGroupMembersAction, createCustomApiCallAction({ baseUrl: () => googleContactsCommon.baseUrl, auth: googleContactsAuth, diff --git a/packages/pieces/community/google-contacts/src/lib/action/batch-create-contacts.ts b/packages/pieces/community/google-contacts/src/lib/action/batch-create-contacts.ts new file mode 100644 index 000000000000..e65065ad4e52 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/batch-create-contacts.ts @@ -0,0 +1,134 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { batchCreateContactsOutputSchema } from '../output-schemas'; + +function optionalString({ source, key }: { source: unknown; key: string }): string | undefined { + const value = googleContactsApi.readString({ source, path: [key] }); + if (value === undefined || value.trim().length === 0) { + return undefined; + } + return value.trim(); +} + +export const googleContactsBatchCreateContactsAction = createAction({ + auth: googleContactsAuth, + name: 'batch_create_contacts', + classification: 'WRITE', + displayName: 'Batch Create Contacts', + description: 'Create up to 200 contacts in a single call.', + audience: 'ai', + aiMetadata: { + description: + 'Creates up to 200 new Google Contacts people in one call and returns the created contacts split into succeeded and failed collections. Use it instead of repeated Create Contact calls when importing a list; a whole-call success can still contain per-contact failures, so always read the failed collection. Not idempotent — every call creates new people, so a retry after a partial failure duplicates the ones that already succeeded.', + idempotent: false, + }, + outputSchema: batchCreateContactsOutputSchema, + props: { + contacts: Property.Array({ + displayName: 'Contacts', + description: 'Up to 200 contacts to create. Each needs at least a name, an email or a phone number.', + required: true, + properties: { + firstName: Property.ShortText({ displayName: 'First Name', required: false }), + middleName: Property.ShortText({ displayName: 'Middle Name', required: false }), + lastName: Property.ShortText({ displayName: 'Last Name', required: false }), + email: Property.ShortText({ displayName: 'Email Address', required: false }), + phoneNumber: Property.ShortText({ displayName: 'Phone Number', required: false }), + company: Property.ShortText({ displayName: 'Company', required: false }), + jobTitle: Property.ShortText({ displayName: 'Job Title', required: false }), + }, + }), + }, + async run(context) { + const entries = context.propsValue.contacts; + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error('Contacts must contain at least one entry.'); + } + if (entries.length > 200) { + throw new Error( + `Contacts accepts at most 200 entries, received ${entries.length}.` + ); + } + const people = entries.map((entry, index) => { + const email = optionalString({ source: entry, key: 'email' }); + const phoneNumber = optionalString({ source: entry, key: 'phoneNumber' }); + const { person, fields } = googleContactsPerson.buildPerson({ + input: { + firstName: optionalString({ source: entry, key: 'firstName' }), + middleName: optionalString({ source: entry, key: 'middleName' }), + lastName: optionalString({ source: entry, key: 'lastName' }), + emails: email === undefined ? [] : [email], + phoneNumbers: phoneNumber === undefined ? [] : [phoneNumber], + company: optionalString({ source: entry, key: 'company' }), + jobTitle: optionalString({ source: entry, key: 'jobTitle' }), + }, + }); + if (fields.length === 0) { + throw new Error( + `Contact ${index + 1} needs at least one of first name, last name, email address or phone number.` + ); + } + return { contactPerson: person }; + }); + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.POST, + path: '/people:batchCreateContacts', + body: { + contacts: people, + readMask: googleContactsApi.contactReadMask.join(','), + }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Batch Create Contacts', + }); + } + const created = googleContactsApi.readArray({ + source: response, + path: ['createdPeople'], + }); + const succeeded = created + .map((item, index) => ({ item, index })) + .filter((entry) => !googleContactsApi.isFailedItem({ response: entry.item })) + .map((entry) => { + const payload = googleContactsApi.readValue({ + source: entry.item, + path: ['person'], + }); + return { + requestIndex: entry.index + 1, + resourceName: googleContactsApi.readString({ + source: payload, + path: ['resourceName'], + }), + contact: googleContactsPerson.summarizePerson({ person: payload }), + }; + }); + const failed = created + .map((item, index) => ({ item, index })) + .filter((entry) => googleContactsApi.isFailedItem({ response: entry.item })) + .map((entry) => ({ + requestIndex: entry.index + 1, + status: googleContactsApi.describeStatus({ + status: googleContactsApi.readValue({ + source: entry.item, + path: ['status'], + }), + }), + })); + return { + succeeded, + failed, + succeededCount: succeeded.length, + failedCount: failed.length, + requestedCount: people.length, + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/batch-delete-contacts.ts b/packages/pieces/community/google-contacts/src/lib/action/batch-delete-contacts.ts new file mode 100644 index 000000000000..28d9d7cbf721 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/batch-delete-contacts.ts @@ -0,0 +1,53 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { batchDeleteContactsOutputSchema } from '../output-schemas'; + +export const googleContactsBatchDeleteContactsAction = createAction({ + auth: googleContactsAuth, + name: 'batch_delete_contacts', + classification: 'DESTRUCTIVE', + displayName: 'Batch Delete Contacts', + description: 'Permanently delete up to 500 contacts in a single call.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes up to 500 Google Contacts people in one call from a list of opaque resourceNames. Irreversible and unrecoverable — the People API has no trash and there is no per-contact confirmation in the response, so verify every resourceName with Batch Get Contacts or List Contacts before calling and prefer Delete Contact when removing a single person. Not idempotent: a repeat call fails on contacts that are already gone.', + idempotent: false, + }, + outputSchema: batchDeleteContactsOutputSchema, + props: { + resourceNames: Property.Array({ + displayName: 'Resource Names', + description: + 'Up to 500 opaque contact resource names such as people/c12345. Every listed contact is deleted permanently and cannot be restored.', + required: true, + }), + }, + async run(context) { + const resourceNames = googleContactsApi.toStringList({ + value: context.propsValue.resourceNames, + label: 'Resource Names', + }); + googleContactsApi.assertBatchSize({ + values: resourceNames, + max: 500, + label: 'Resource Names', + }); + try { + await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.POST, + path: '/people:batchDeleteContacts', + body: { resourceNames }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Batch Delete Contacts', + }); + } + return { deleted: true, resourceNames, count: resourceNames.length }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/batch-get-contact-groups.ts b/packages/pieces/community/google-contacts/src/lib/action/batch-get-contact-groups.ts new file mode 100644 index 000000000000..1cc3880c57d2 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/batch-get-contact-groups.ts @@ -0,0 +1,89 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsGroup } from '../common/contact-group'; +import { batchGetContactGroupsOutputSchema } from '../output-schemas'; + +export const googleContactsBatchGetContactGroupsAction = createAction({ + auth: googleContactsAuth, + name: 'batch_get_contact_groups', + classification: 'READ', + displayName: 'Batch Get Contact Groups', + description: 'Read up to 200 contact groups in one call by their resource names.', + audience: 'ai', + aiMetadata: { + description: + 'Reads up to 200 Google Contacts groups in one call from a list of opaque resourceNames, returning succeeded and failed collections. Use it instead of repeated Get Contact Group calls; a whole-call success can still contain per-group failures, so always read the failed collection. Resolve the resource names with List Contact Groups. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: batchGetContactGroupsOutputSchema, + props: { + resourceNames: Property.Array({ + displayName: 'Resource Names', + description: + 'Up to 200 opaque contact group resource names such as contactGroups/abc123, resolved with List Contact Groups.', + required: true, + }), + maxMembers: Property.Number({ + displayName: 'Max Members', + description: + 'How many member resource names to return per group. Defaults to 0, which returns none.', + required: false, + }), + }, + async run(context) { + const resourceNames = googleContactsApi.toStringList({ + value: context.propsValue.resourceNames, + label: 'Resource Names', + }); + googleContactsApi.assertBatchSize({ + values: resourceNames, + max: 200, + label: 'Resource Names', + }); + const maxMembers = Math.max(0, context.propsValue.maxMembers ?? 0); + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: '/contactGroups:batchGet', + queryParams: { + groupFields: googleContactsApi.contactGroupFields.join(','), + maxMembers: String(maxMembers), + }, + repeatedQueryParams: { resourceNames }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Batch Get Contact Groups', + }); + } + const items = googleContactsApi + .readArray({ source: response, path: ['responses'] }) + .map((item, index) => ({ + resourceName: + googleContactsApi.readString({ + source: item, + path: ['requestedResourceName'], + }) ?? + resourceNames[index] ?? + '', + response: item, + })); + const { succeeded, failed } = googleContactsApi.splitItemResponses({ + items, + payloadKey: 'contactGroup', + }); + return { + succeeded: succeeded.map((item) => + googleContactsGroup.summarizeGroup({ group: item.payload }) + ), + failed, + succeededCount: succeeded.length, + failedCount: failed.length, + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/batch-get-contacts.ts b/packages/pieces/community/google-contacts/src/lib/action/batch-get-contacts.ts new file mode 100644 index 000000000000..4d22c62e2a9d --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/batch-get-contacts.ts @@ -0,0 +1,83 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { batchGetContactsOutputSchema } from '../output-schemas'; + +export const googleContactsBatchGetContactsAction = createAction({ + auth: googleContactsAuth, + name: 'batch_get_contacts', + classification: 'READ', + displayName: 'Batch Get Contacts', + description: 'Read up to 200 contacts in one call by their resource names.', + audience: 'ai', + aiMetadata: { + description: + 'Reads up to 200 Google Contacts people in one call from a list of opaque resourceNames and returns each full contact with its current etag, split into succeeded and failed collections. Use it instead of repeated Get Contact calls, and as the input source for Batch Update Contacts. A whole-call success can still contain per-contact failures, so always read the failed collection. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: batchGetContactsOutputSchema, + props: { + resourceNames: Property.Array({ + displayName: 'Resource Names', + description: + 'Up to 200 opaque contact resource names such as people/c12345, resolved with List Contacts or Search Contacts.', + required: true, + }), + }, + async run(context) { + const resourceNames = googleContactsApi.toStringList({ + value: context.propsValue.resourceNames, + label: 'Resource Names', + }); + googleContactsApi.assertBatchSize({ + values: resourceNames, + max: 200, + label: 'Resource Names', + }); + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: '/people:batchGet', + queryParams: { + personFields: googleContactsApi.contactReadMask.join(','), + }, + repeatedQueryParams: { resourceNames }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Batch Get Contacts', + }); + } + const items = googleContactsApi + .readArray({ source: response, path: ['responses'] }) + .map((item, index) => ({ + resourceName: + googleContactsApi.readString({ + source: item, + path: ['requestedResourceName'], + }) ?? + resourceNames[index] ?? + '', + response: item, + })); + const { succeeded, failed } = googleContactsApi.splitItemResponses({ + items, + payloadKey: 'person', + }); + return { + succeeded: succeeded.map((item) => ({ + resourceName: item.resourceName, + contact: googleContactsPerson.summarizePerson({ person: item.payload }), + person: item.payload, + })), + failed, + succeededCount: succeeded.length, + failedCount: failed.length, + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/batch-update-contacts.ts b/packages/pieces/community/google-contacts/src/lib/action/batch-update-contacts.ts new file mode 100644 index 000000000000..77f6f88b634a --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/batch-update-contacts.ts @@ -0,0 +1,167 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { batchUpdateContactsOutputSchema } from '../output-schemas'; + +function toPerson({ entry, index }: { entry: unknown; index: number }): { + resourceName: string; + person: Record; + fields: string[]; +} { + const record = googleContactsApi.readRecord({ source: entry, path: [] }); + if (record === undefined) { + throw new Error(`Contact ${index + 1} must be a Person object.`); + } + const resourceName = googleContactsApi.readString({ + source: record, + path: ['resourceName'], + }); + const etag = googleContactsApi.readString({ source: record, path: ['etag'] }); + if (resourceName === undefined || etag === undefined) { + throw new Error( + `Contact ${index + 1} must carry both resourceName and etag exactly as returned by Batch Get Contacts.` + ); + } + const fields = Object.keys(record).filter((key) => + googleContactsApi.updatablePersonFields.includes(key) + ); + const person = Object.fromEntries( + Object.entries(record).filter( + ([key]) => + key === 'etag' || + key === 'resourceName' || + googleContactsApi.updatablePersonFields.includes(key) + ) + ); + return { resourceName, person, fields }; +} + +function groupBySignature({ + parsed, +}: { + parsed: { resourceName: string; person: Record; fields: string[] }[]; +}): { updateMask: string; contacts: Record> }[] { + const groups = new Map>>(); + for (const item of parsed) { + const signature = [...item.fields].sort().join(','); + const existing = groups.get(signature); + if (existing === undefined) { + groups.set(signature, { [item.resourceName]: item.person }); + } else { + existing[item.resourceName] = item.person; + } + } + return [...groups.entries()].map(([updateMask, contacts]) => ({ + updateMask, + contacts, + })); +} + +export const googleContactsBatchUpdateContactsAction = createAction({ + auth: googleContactsAuth, + name: 'batch_update_contacts', + classification: 'WRITE', + displayName: 'Batch Update Contacts', + description: 'Update up to 200 contacts in a single call from full Person objects.', + audience: 'ai', + aiMetadata: { + description: + 'Updates up to 200 Google Contacts people in one call. Feed it the full Person objects returned by Batch Get Contacts, each still carrying its own resourceName and etag, with the values you want changed edited in place. The people are grouped by which field groups they carry and one request is issued per group, so a person carrying only a phone number and a person carrying only an email can be updated together; each request uses the field mask of its own group. Within a group the mask is applied to every person in it, so never hand-build partial Person objects here — a group a person no longer carries is erased on that person. Prefer Update Contact Fields for a single contact. Because the work is split across requests, this action can partially apply: one group can be written while another is rejected outright. It therefore does not throw when a request fails — it records every contact in that request under failed and carries on. Never treat a returned result as wholesale success and never assume an absent error means everything applied; always read failed and failedCount, which also cover per-contact rejections such as stale etags. Every submitted person must carry at least one updatable field group. Before retrying, re-read the people so the already-updated ones carry fresh etags.', + idempotent: true, + }, + outputSchema: batchUpdateContactsOutputSchema, + props: { + contacts: Property.Json({ + displayName: 'Contacts', + description: + 'JSON array of up to 200 full Person objects taken from Batch Get Contacts, each keeping its resourceName and etag, with the fields to change edited in place.', + required: true, + }), + }, + async run(context) { + const entries = context.propsValue.contacts; + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error( + 'Contacts must be a JSON array holding at least one Person object.' + ); + } + if (entries.length > 200) { + throw new Error( + `Contacts accepts at most 200 entries, received ${entries.length}.` + ); + } + const parsed = entries.map((entry, index) => toPerson({ entry, index })); + const withoutGroups = parsed + .filter((item) => item.fields.length === 0) + .map((item) => item.resourceName); + if (withoutGroups.length > 0) { + throw new Error( + `Every submitted person must carry at least one updatable field group, because a person with no group has nothing to update. These people carry none: ${withoutGroups.join( + ', ' + )}.` + ); + } + const groups = groupBySignature({ parsed }); + const succeeded: { resourceName: string; contact: Record; person: unknown }[] = + []; + const failed: { resourceName: string; status: string; updateMask: string }[] = []; + for (const group of groups) { + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.POST, + path: '/people:batchUpdateContacts', + body: { + contacts: group.contacts, + updateMask: group.updateMask, + readMask: googleContactsApi.contactReadMask.join(','), + }, + }); + } catch (error) { + const status = googleContactsApi.toApiError({ + error, + operation: `Batch Update Contacts (field mask ${group.updateMask})`, + }).message; + for (const resourceName of Object.keys(group.contacts)) { + failed.push({ resourceName, status, updateMask: group.updateMask }); + } + continue; + } + const updateResult = googleContactsApi.readRecord({ + source: response, + path: ['updateResult'], + }); + const items = Object.entries(updateResult ?? {}).map( + ([resourceName, value]) => ({ resourceName, response: value }) + ); + const split = googleContactsApi.splitItemResponses({ + items, + payloadKey: 'person', + }); + for (const item of split.succeeded) { + succeeded.push({ + resourceName: item.resourceName, + contact: googleContactsPerson.summarizePerson({ person: item.payload }), + person: item.payload, + }); + } + for (const item of split.failed) { + failed.push({ + resourceName: item.resourceName, + status: item.status, + updateMask: group.updateMask, + }); + } + } + return { + succeeded, + failed, + succeededCount: succeeded.length, + failedCount: failed.length, + updateMasks: groups.map((group) => group.updateMask), + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/create-contact-agent.ts b/packages/pieces/community/google-contacts/src/lib/action/create-contact-agent.ts new file mode 100644 index 000000000000..6feaa42c31f7 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/create-contact-agent.ts @@ -0,0 +1,105 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { createContactOutputSchema } from '../output-schemas'; + +export const googleContactsCreateContactAction = createAction({ + auth: googleContactsAuth, + name: 'create_contact', + classification: 'WRITE', + displayName: 'Create Contact', + description: 'Create a new contact in the connected Google account.', + audience: 'ai', + aiMetadata: { + description: + 'Creates one new person in the connected Google Contacts account from name, nickname, email, phone, organization and notes fields. Supply at least one of name, email or phone; email addresses and phone numbers are arrays of strings, so pass ["ada@example.com"] rather than a bare string. Use Batch Create Contacts for many at once, and Update Contact Fields to change someone who already exists. Not idempotent — every call creates a separate person, so check with Search Contacts first if duplicates matter.', + idempotent: false, + }, + outputSchema: createContactOutputSchema, + props: { + firstName: Property.ShortText({ + displayName: 'First Name', + required: false, + }), + middleName: Property.ShortText({ + displayName: 'Middle Name', + required: false, + }), + lastName: Property.ShortText({ + displayName: 'Last Name', + required: false, + }), + nickname: Property.ShortText({ + displayName: 'Nickname', + required: false, + }), + emails: Property.Array({ + displayName: 'Email Addresses', + description: + 'Array of email address strings, for example ["ada@example.com"]. A single address still has to be wrapped in an array.', + required: false, + }), + phoneNumbers: Property.Array({ + displayName: 'Phone Numbers', + description: + 'Array of phone number strings, for example ["+1 555 0100"]. A single number still has to be wrapped in an array.', + required: false, + }), + company: Property.ShortText({ + displayName: 'Company', + required: false, + }), + jobTitle: Property.ShortText({ + displayName: 'Job Title', + required: false, + }), + biography: Property.LongText({ + displayName: 'Notes', + description: 'Free-text notes stored on the contact biography.', + required: false, + }), + }, + async run(context) { + const emails = googleContactsApi.toStringList({ + value: context.propsValue.emails, + label: 'Email Addresses', + }); + const phoneNumbers = googleContactsApi.toStringList({ + value: context.propsValue.phoneNumbers, + label: 'Phone Numbers', + }); + const { person, fields } = googleContactsPerson.buildPerson({ + input: { + firstName: context.propsValue.firstName, + middleName: context.propsValue.middleName, + lastName: context.propsValue.lastName, + nickname: context.propsValue.nickname, + emails, + phoneNumbers, + company: context.propsValue.company, + jobTitle: context.propsValue.jobTitle, + biography: context.propsValue.biography, + }, + }); + if (fields.length === 0) { + throw new Error( + 'Create Contact needs at least one of first name, last name, email address or phone number.' + ); + } + try { + return await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.POST, + path: '/people:createContact', + queryParams: { + personFields: googleContactsApi.contactReadMask.join(','), + }, + body: person, + }); + } catch (error) { + throw googleContactsApi.toApiError({ error, operation: 'Create Contact' }); + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/create-contact-group.ts b/packages/pieces/community/google-contacts/src/lib/action/create-contact-group.ts new file mode 100644 index 000000000000..3b10fa42f0cf --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/create-contact-group.ts @@ -0,0 +1,120 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsGroup } from '../common/contact-group'; +import { createContactGroupOutputSchema } from '../output-schemas'; + +const maxPages = 10; + +async function findGroupByName({ + accessToken, + name, +}: { + accessToken: string; + name: string; +}): Promise { + let pageToken: string | undefined = undefined; + for (let page = 0; page < maxPages; page++) { + const response: Record = await googleContactsApi.sendRequest({ + accessToken, + method: HttpMethod.GET, + path: '/contactGroups', + queryParams: { + groupFields: googleContactsApi.contactGroupFields.join(','), + pageSize: '1000', + pageToken, + }, + }); + const match = googleContactsApi + .readArray({ source: response, path: ['contactGroups'] }) + .find((group) => googleContactsGroup.matchesName({ group, name })); + if (match !== undefined) { + return match; + } + pageToken = googleContactsApi.readString({ + source: response, + path: ['nextPageToken'], + }); + if (pageToken === undefined) { + return undefined; + } + } + return undefined; +} + +export const googleContactsCreateContactGroupAction = createAction({ + auth: googleContactsAuth, + name: 'create_contact_group', + classification: 'WRITE', + displayName: 'Create Contact Group', + description: 'Create a contact group, or return the existing one with that name.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a contact group (label) with the given name in the connected Google account, or returns the existing group when one already carries that name, reporting which happened through the created flag. Use it before Add Or Remove Contact Group Members when the target group may not exist yet. Safe to retry: the lookup runs first and a duplicate-name rejection is resolved by returning the existing group.', + idempotent: true, + }, + outputSchema: createContactGroupOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Group Name', + description: 'Name of the contact group to find or create.', + required: true, + }), + }, + async run(context) { + const name = context.propsValue.name.trim(); + if (name.length === 0) { + throw new Error('Group Name must not be empty.'); + } + const accessToken = context.auth.access_token; + try { + const existing = await findGroupByName({ accessToken, name }); + if (existing !== undefined) { + return { + created: false, + contactGroup: googleContactsGroup.summarizeGroup({ group: existing }), + }; + } + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Create Contact Group (looking up existing groups)', + }); + } + try { + const response = await googleContactsApi.sendRequest({ + accessToken, + method: HttpMethod.POST, + path: '/contactGroups', + body: { + contactGroup: { name }, + readGroupFields: googleContactsApi.contactGroupFields.join(','), + }, + }); + return { + created: true, + contactGroup: googleContactsGroup.summarizeGroup({ group: response }), + }; + } catch (error) { + if (!googleContactsApi.isConflict({ error })) { + throw googleContactsApi.toApiError({ + error, + operation: 'Create Contact Group', + }); + } + const raced = await findGroupByName({ accessToken, name }); + if (raced === undefined) { + throw googleContactsApi.toApiError({ + error, + operation: 'Create Contact Group', + }); + } + return { + created: false, + contactGroup: googleContactsGroup.summarizeGroup({ group: raced }), + }; + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/create-contact.ts b/packages/pieces/community/google-contacts/src/lib/action/create-contact.ts index f18f304643da..58d8d2cae24c 100644 --- a/packages/pieces/community/google-contacts/src/lib/action/create-contact.ts +++ b/packages/pieces/community/google-contacts/src/lib/action/create-contact.ts @@ -14,7 +14,7 @@ export const googleContactsAddContactAction = createAction({ name: 'add_contact', classification: 'WRITE', description: 'Add a contact to a Google Contacts account', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new person in the authenticated Google Contacts account from name, email, phone, company, and job title fields. Use when an agent needs to add someone to address book contacts; first and last name are required. Not idempotent — each call creates a separate contact even with identical input, so guard against duplicates.', idempotent: false }, displayName: 'Add Contact', outputSchema: addContactOutputSchema, diff --git a/packages/pieces/community/google-contacts/src/lib/action/delete-contact-group.ts b/packages/pieces/community/google-contacts/src/lib/action/delete-contact-group.ts new file mode 100644 index 000000000000..fbad577734bd --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/delete-contact-group.ts @@ -0,0 +1,53 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { deleteContactGroupOutputSchema } from '../output-schemas'; + +export const googleContactsDeleteContactGroupAction = createAction({ + auth: googleContactsAuth, + name: 'delete_contact_group', + classification: 'DESTRUCTIVE', + displayName: 'Delete Contact Group', + description: 'Delete a user-created contact group.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes one user-created Google Contacts group by its opaque resourceName (contactGroups/{id}). By default only the group is removed and its members stay in the address book; turning on Delete Contacts also permanently deletes every contact in the group, which cannot be undone. Resolve the resourceName with List Contact Groups; Google-managed system groups cannot be deleted. Not idempotent: a repeat call fails because the group is already gone.', + idempotent: false, + }, + outputSchema: deleteContactGroupOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact group resource name such as contactGroups/abc123. Resolve it with List Contact Groups.', + required: true, + }), + deleteContacts: Property.Checkbox({ + displayName: 'Also Delete Contacts', + description: + 'When enabled, every contact in the group is permanently deleted as well. Leave off to remove only the group.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + const deleteContacts = context.propsValue.deleteContacts === true; + try { + await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.DELETE, + path: `/${resourceName}`, + queryParams: { deleteContacts: String(deleteContacts) }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Delete Contact Group', + }); + } + return { deleted: true, resourceName, deletedContacts: deleteContacts }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/delete-contact-photo.ts b/packages/pieces/community/google-contacts/src/lib/action/delete-contact-photo.ts new file mode 100644 index 000000000000..78d48714a3e0 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/delete-contact-photo.ts @@ -0,0 +1,57 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { deleteContactPhotoOutputSchema } from '../output-schemas'; + +export const googleContactsDeleteContactPhotoAction = createAction({ + auth: googleContactsAuth, + name: 'delete_contact_photo', + classification: 'WRITE', + displayName: 'Delete Contact Photo', + description: 'Remove the photo of an existing contact.', + audience: 'ai', + aiMetadata: { + description: + 'Removes the photo from one Google Contacts person, leaving the rest of the contact untouched. Supply the opaque resourceName from List Contacts or Search Contacts; use Update Contact Photo to set a new image instead. The removed image is not recoverable through this API.', + idempotent: false, + }, + outputSchema: deleteContactPhotoOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts.', + required: true, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.DELETE, + path: `/${resourceName}:deleteContactPhoto`, + queryParams: { + personFields: googleContactsApi.contactReadMask.join(','), + }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Delete Contact Photo', + }); + } + const person = googleContactsApi.readRecord({ + source: response, + path: ['person'], + }); + if (person === undefined) { + throw new Error( + 'Delete Contact Photo could not read the updated contact back from Google Contacts; re-read the contact with Get Contact to confirm the photo was removed.' + ); + } + return person; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/delete-contact.ts b/packages/pieces/community/google-contacts/src/lib/action/delete-contact.ts new file mode 100644 index 000000000000..2c2a325b7923 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/delete-contact.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { deleteContactOutputSchema } from '../output-schemas'; + +export const googleContactsDeleteContactAction = createAction({ + auth: googleContactsAuth, + name: 'delete_contact', + classification: 'DESTRUCTIVE', + displayName: 'Delete Contact', + description: 'Permanently delete a contact by its resource name.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one Google Contacts person by its opaque resourceName (people/{id}). Irreversible — the People API has no trash, so confirm the contact with Get Contact or List Contacts first; use Batch Delete Contacts for many at once. Not idempotent: a repeat call fails because the contact is already gone.', + idempotent: false, + }, + outputSchema: deleteContactOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts. Deletion cannot be undone.', + required: true, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + try { + await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.DELETE, + path: `/${resourceName}:deleteContact`, + }); + } catch (error) { + throw googleContactsApi.toApiError({ error, operation: 'Delete Contact' }); + } + return { deleted: true, resourceName }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/get-contact-group.ts b/packages/pieces/community/google-contacts/src/lib/action/get-contact-group.ts new file mode 100644 index 000000000000..be536800981a --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/get-contact-group.ts @@ -0,0 +1,56 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsGroup } from '../common/contact-group'; +import { getContactGroupOutputSchema } from '../output-schemas'; + +export const googleContactsGetContactGroupAction = createAction({ + auth: googleContactsAuth, + name: 'get_contact_group', + classification: 'READ', + displayName: 'Get Contact Group', + description: 'Read one contact group, optionally with its member resource names.', + audience: 'ai', + aiMetadata: { + description: + 'Reads one Google Contacts group by its opaque resourceName (contactGroups/{id}) and returns its name, type, member count and, when Max Members is above zero, the resource names of its members. Resolve the resourceName with List Contact Groups; use Batch Get Contact Groups for several groups. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getContactGroupOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact group resource name such as contactGroups/abc123, or a system group like contactGroups/myContacts. Resolve it with List Contact Groups.', + required: true, + }), + maxMembers: Property.Number({ + displayName: 'Max Members', + description: + 'How many member resource names to return. Defaults to 0, which returns none.', + required: false, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + const maxMembers = Math.max(0, context.propsValue.maxMembers ?? 0); + try { + const response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: `/${resourceName}`, + queryParams: { + groupFields: googleContactsApi.contactGroupFields.join(','), + maxMembers: String(maxMembers), + }, + }); + return googleContactsGroup.summarizeGroup({ group: response }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Get Contact Group', + }); + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/get-contact.ts b/packages/pieces/community/google-contacts/src/lib/action/get-contact.ts new file mode 100644 index 000000000000..2558df57c15d --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/get-contact.ts @@ -0,0 +1,43 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { getContactOutputSchema } from '../output-schemas'; + +export const googleContactsGetContactAction = createAction({ + auth: googleContactsAuth, + name: 'get_contact', + classification: 'READ', + displayName: 'Get Contact', + description: 'Read a single contact by its resource name.', + audience: 'ai', + aiMetadata: { + description: + 'Reads one Google Contacts person by its opaque resourceName (people/{id}) and returns names, emails, phone numbers, organizations, memberships and the current etag. Obtain the resourceName from List Contacts or Search Contacts; do not construct or parse it. Use Batch Get Contacts for more than one contact. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getContactOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts.', + required: true, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + try { + return await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: `/${resourceName}`, + queryParams: { + personFields: googleContactsApi.contactReadMask.join(','), + }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ error, operation: 'Get Contact' }); + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/list-contact-groups.ts b/packages/pieces/community/google-contacts/src/lib/action/list-contact-groups.ts new file mode 100644 index 000000000000..e5c2a68475b7 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/list-contact-groups.ts @@ -0,0 +1,66 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsGroup } from '../common/contact-group'; +import { listContactGroupsOutputSchema } from '../output-schemas'; + +const maxPages = 10; + +export const googleContactsListContactGroupsAction = createAction({ + auth: googleContactsAuth, + name: 'list_contact_groups', + classification: 'SEARCH', + displayName: 'List Contact Groups', + description: 'List every contact group of the connected Google account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the contact groups (labels) of the connected Google account with the opaque resourceName, name, type and member count of each. Use it to resolve a group name to its resourceName before Get Contact Group, Update Contact Group, Delete Contact Group or Add Or Remove Contact Group Members. Member resource names are not included here — read a single group with Get Contact Group for those. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listContactGroupsOutputSchema, + props: {}, + async run(context) { + const groups: Record[] = []; + let pageToken: string | undefined = undefined; + try { + for (let page = 0; page < maxPages; page++) { + const response: Record = + await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: '/contactGroups', + queryParams: { + groupFields: googleContactsApi.contactGroupFields.join(','), + pageSize: '1000', + pageToken, + }, + }); + for (const group of googleContactsApi.readArray({ + source: response, + path: ['contactGroups'], + })) { + groups.push(googleContactsGroup.summarizeGroup({ group })); + } + pageToken = googleContactsApi.readString({ + source: response, + path: ['nextPageToken'], + }); + if (pageToken === undefined) { + break; + } + } + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'List Contact Groups', + }); + } + return { + contactGroups: groups, + count: groups.length, + hasMore: pageToken !== undefined, + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/list-contacts.ts b/packages/pieces/community/google-contacts/src/lib/action/list-contacts.ts new file mode 100644 index 000000000000..fec807de8646 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/list-contacts.ts @@ -0,0 +1,73 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { listContactsOutputSchema } from '../output-schemas'; + +const maxPages = 10; + +export const googleContactsListContactsAction = createAction({ + auth: googleContactsAuth, + name: 'list_contacts', + classification: 'SEARCH', + displayName: 'List Contacts', + description: 'List the contacts of the connected Google account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the connected account contacts with the resourceName and etag every write action needs, plus display name, emails, phone numbers and organization. Use this to resolve a person name to a resourceName before Update Contact Fields, Delete Contact or group membership changes; use Search Contacts when you already have a query string. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listContactsOutputSchema, + props: { + maxResults: Property.Number({ + displayName: 'Max Results', + description: 'Maximum number of contacts to return. Defaults to 200.', + required: false, + }), + }, + async run(context) { + const limit = context.propsValue.maxResults ?? 200; + const collected: Record[] = []; + let pageToken: string | undefined = undefined; + try { + for (let page = 0; page < maxPages; page++) { + const pageSize = Math.min(1000, Math.max(1, limit - collected.length)); + const response: Record = + await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: '/people/me/connections', + queryParams: { + personFields: googleContactsApi.contactReadMask.join(','), + pageSize: String(pageSize), + pageToken, + }, + }); + const connections = googleContactsApi.readArray({ + source: response, + path: ['connections'], + }); + for (const person of connections) { + collected.push(googleContactsPerson.summarizePerson({ person })); + } + pageToken = googleContactsApi.readString({ + source: response, + path: ['nextPageToken'], + }); + if (pageToken === undefined || collected.length >= limit) { + break; + } + } + } catch (error) { + throw googleContactsApi.toApiError({ error, operation: 'List Contacts' }); + } + const contacts = collected.slice(0, limit); + return { + contacts, + count: contacts.length, + hasMore: pageToken !== undefined, + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/modify-contact-group-members.ts b/packages/pieces/community/google-contacts/src/lib/action/modify-contact-group-members.ts new file mode 100644 index 000000000000..782c5b2f62c6 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/modify-contact-group-members.ts @@ -0,0 +1,102 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { modifyContactGroupMembersOutputSchema } from '../output-schemas'; + +export const googleContactsModifyContactGroupMembersAction = createAction({ + auth: googleContactsAuth, + name: 'modify_contact_group_members', + classification: 'WRITE', + displayName: 'Add Or Remove Contact Group Members', + description: 'Add contacts to a contact group or remove them from it.', + audience: 'ai', + aiMetadata: { + description: + 'Adds contacts to a Google Contacts group and removes contacts from it in one call, taking opaque people/{id} resource names resolved with List Contacts or Search Contacts and a contactGroups/{id} group resolved with List Contact Groups. Additions are accepted by user-created groups and by the system groups contactGroups/myContacts and contactGroups/starred only; other system groups accept removals only. Combined additions and removals are capped at 1000, and the response reports contacts that were not found. Safe to retry — re-applying the same membership changes converges.', + idempotent: true, + }, + outputSchema: modifyContactGroupMembersOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Contact Group Resource Name', + description: + 'Opaque contact group resource name such as contactGroups/abc123, contactGroups/myContacts or contactGroups/starred. Resolve it with List Contact Groups.', + required: true, + }), + resourceNamesToAdd: Property.Array({ + displayName: 'Contacts To Add', + description: + 'Opaque contact resource names such as people/c12345 to add to the group.', + required: false, + }), + resourceNamesToRemove: Property.Array({ + displayName: 'Contacts To Remove', + description: + 'Opaque contact resource names such as people/c12345 to remove from the group.', + required: false, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + const resourceNamesToAdd = googleContactsApi.toStringList({ + value: context.propsValue.resourceNamesToAdd, + label: 'Contacts To Add', + }); + const resourceNamesToRemove = googleContactsApi.toStringList({ + value: context.propsValue.resourceNamesToRemove, + label: 'Contacts To Remove', + }); + const total = resourceNamesToAdd.length + resourceNamesToRemove.length; + if (total === 0) { + throw new Error( + 'Add Or Remove Contact Group Members needs at least one contact to add or remove.' + ); + } + if (total > 1000) { + throw new Error( + `Contacts to add and remove are capped at 1000 combined, received ${total}.` + ); + } + if ( + resourceNamesToAdd.length > 0 && + googleContactsApi.systemGroupsRejectingAdds.includes(resourceName) + ) { + throw new Error( + `The system group ${resourceName} does not accept added members; only contactGroups/myContacts, contactGroups/starred and user-created groups do.` + ); + } + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.POST, + path: `/${resourceName}/members:modify`, + body: { resourceNamesToAdd, resourceNamesToRemove }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Add Or Remove Contact Group Members', + }); + } + const notFound = googleContactsApi + .readArray({ source: response, path: ['notFoundResourceNames'] }) + .filter((value): value is string => typeof value === 'string'); + const notRemovable = googleContactsApi + .readArray({ + source: response, + path: ['canNotRemoveLastContactGroupResourceNames'], + }) + .filter((value): value is string => typeof value === 'string'); + return { + resourceName, + added: resourceNamesToAdd.filter((name) => !notFound.includes(name)), + removed: resourceNamesToRemove.filter( + (name) => !notFound.includes(name) && !notRemovable.includes(name) + ), + notFoundResourceNames: notFound, + canNotRemoveLastContactGroupResourceNames: notRemovable, + }; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/search-contact.ts b/packages/pieces/community/google-contacts/src/lib/action/search-contact.ts index b2ce4832e456..8457e050f3be 100644 --- a/packages/pieces/community/google-contacts/src/lib/action/search-contact.ts +++ b/packages/pieces/community/google-contacts/src/lib/action/search-contact.ts @@ -15,7 +15,7 @@ export const googleContactsSearchContactsAction = createAction({ name: 'search_contact', classification: 'SEARCH', description: 'Search contacts in Google Contacts account.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Searches the authenticated Google Contacts account for people matching a plain-text query, which prefix-matches against contact fields. Use when an agent needs to find a contact or its resourceName before updating it; the query is required and a read mask selects which fields come back (max 30 results). Read-only and idempotent.', idempotent: true }, displayName: 'Search Contacts', outputSchema: searchContactsOutputSchema, diff --git a/packages/pieces/community/google-contacts/src/lib/action/search-contacts.ts b/packages/pieces/community/google-contacts/src/lib/action/search-contacts.ts new file mode 100644 index 000000000000..c220d48c8b16 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/search-contacts.ts @@ -0,0 +1,73 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { searchContactsAtomicOutputSchema } from '../output-schemas'; + +export const googleContactsSearchContactsAtomicAction = createAction({ + auth: googleContactsAuth, + name: 'search_contacts', + classification: 'SEARCH', + displayName: 'Search Contacts', + description: 'Search the connected account contacts by a plain-text query.', + audience: 'ai', + aiMetadata: { + description: + 'Searches the connected Google Contacts account for people whose fields prefix-match a plain-text query and returns each match with its resourceName and etag. Use it to resolve a name, email or phone fragment into a resourceName before an update or delete; use List Contacts to enumerate everything instead. Matching is prefix-only ("foo n" matches "foo name", "oo n" does not) and capped at 30 results. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: searchContactsAtomicOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: + 'Plain-text query prefix-matched against contact fields such as name, email and phone number.', + required: true, + }), + pageSize: Property.Number({ + displayName: 'Page Size', + description: 'Number of results to return, maximum 30. Defaults to 30.', + required: false, + }), + }, + async run(context) { + const readMask = googleContactsApi.contactReadMask.join(','); + const pageSize = Math.min(30, Math.max(1, context.propsValue.pageSize ?? 30)); + try { + await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: '/people:searchContacts', + queryParams: { query: '', readMask, pageSize: '1' }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Search Contacts warm-up', + }); + } + try { + const response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: '/people:searchContacts', + queryParams: { + query: context.propsValue.query, + readMask, + pageSize: String(pageSize), + }, + }); + const results = googleContactsApi + .readArray({ source: response, path: ['results'] }) + .map((result) => + googleContactsPerson.summarizePerson({ + person: googleContactsApi.readValue({ source: result, path: ['person'] }), + }) + ); + return { results, count: results.length }; + } catch (error) { + throw googleContactsApi.toApiError({ error, operation: 'Search Contacts' }); + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/update-contact-fields.ts b/packages/pieces/community/google-contacts/src/lib/action/update-contact-fields.ts new file mode 100644 index 000000000000..d5f7010b968a --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/update-contact-fields.ts @@ -0,0 +1,163 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsPerson } from '../common/person'; +import { updateContactFieldsOutputSchema } from '../output-schemas'; + +export const googleContactsUpdateContactFieldsAction = createAction({ + auth: googleContactsAuth, + name: 'update_contact_fields', + classification: 'WRITE', + displayName: 'Update Contact Fields', + description: 'Update only the supplied fields on an existing contact.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing Google Contacts person, changing only the fields you supply and leaving every other field untouched. It reads the contact first, so it resolves the required etag and field mask itself — you never pass them. Supply the opaque resourceName from List Contacts or Search Contacts. A non-empty email or phone list replaces the stored list for that field wholesale; first/middle/last name and company/job title are merged over the current values. Leaving a list empty does NOT clear it, because the platform cannot distinguish an untouched list from an emptied one: to remove every email address or every phone number, including the last one, set clearEmails or clearPhoneNumbers to true. Setting a clear flag while also supplying a non-empty list for the same field is rejected. Safe to retry: re-applying the same values converges, and a concurrent edit surfaces as a re-read-and-retry error.', + idempotent: true, + }, + outputSchema: updateContactFieldsOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts.', + required: true, + }), + firstName: Property.ShortText({ + displayName: 'First Name', + required: false, + }), + middleName: Property.ShortText({ + displayName: 'Middle Name', + required: false, + }), + lastName: Property.ShortText({ + displayName: 'Last Name', + required: false, + }), + nickname: Property.ShortText({ + displayName: 'Nickname', + required: false, + }), + emails: Property.Array({ + displayName: 'Email Addresses', + description: + 'Array of email address strings, for example ["ada@example.com"]. Replaces every email address stored on the contact. Leaving it empty keeps the stored addresses; it does not clear them, because an untouched list and an emptied one cannot be told apart. Use Clear Email Addresses to remove them.', + required: false, + }), + clearEmails: Property.Checkbox({ + displayName: 'Clear Email Addresses', + description: + 'Remove every email address from the contact, including the last one. This is the only way to clear them. Cannot be combined with a non-empty Email Addresses list.', + required: false, + defaultValue: false, + }), + phoneNumbers: Property.Array({ + displayName: 'Phone Numbers', + description: + 'Array of phone number strings, for example ["+1 555 0100"]. Replaces every phone number stored on the contact. Leaving it empty keeps the stored numbers; it does not clear them, because an untouched list and an emptied one cannot be told apart. Use Clear Phone Numbers to remove them.', + required: false, + }), + clearPhoneNumbers: Property.Checkbox({ + displayName: 'Clear Phone Numbers', + description: + 'Remove every phone number from the contact, including the last one. This is the only way to clear them. Cannot be combined with a non-empty Phone Numbers list.', + required: false, + defaultValue: false, + }), + company: Property.ShortText({ + displayName: 'Company', + required: false, + }), + jobTitle: Property.ShortText({ + displayName: 'Job Title', + required: false, + }), + biography: Property.LongText({ + displayName: 'Notes', + required: false, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + const clearEmails = context.propsValue.clearEmails === true; + const clearPhoneNumbers = context.propsValue.clearPhoneNumbers === true; + const emails = googleContactsApi.toStringList({ + value: context.propsValue.emails, + label: 'Email Addresses', + }); + const phoneNumbers = googleContactsApi.toStringList({ + value: context.propsValue.phoneNumbers, + label: 'Phone Numbers', + }); + if (clearEmails && emails.length > 0) { + throw new Error( + 'Clear Email Addresses removes every email address, so it cannot be combined with an Email Addresses list. Either clear the list or turn the checkbox off.' + ); + } + if (clearPhoneNumbers && phoneNumbers.length > 0) { + throw new Error( + 'Clear Phone Numbers removes every phone number, so it cannot be combined with a Phone Numbers list. Either clear the list or turn the checkbox off.' + ); + } + const input = { + firstName: context.propsValue.firstName, + middleName: context.propsValue.middleName, + lastName: context.propsValue.lastName, + nickname: context.propsValue.nickname, + emails, + phoneNumbers, + clearEmails, + clearPhoneNumbers, + company: context.propsValue.company, + jobTitle: context.propsValue.jobTitle, + biography: context.propsValue.biography, + }; + const { fields: requestedFields } = googleContactsPerson.buildPerson({ input }); + if (requestedFields.length === 0) { + throw new Error( + 'Update Contact Fields needs at least one field to change; every field prop was left empty.' + ); + } + let current: Record; + try { + current = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: `/${resourceName}`, + queryParams: { personFields: requestedFields.join(',') }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Update Contact Fields (reading the contact)', + }); + } + const etag = googleContactsApi.readString({ source: current, path: ['etag'] }); + if (etag === undefined) { + throw new Error( + 'Update Contact Fields could not read the current etag of the contact; re-read the contact and retry.' + ); + } + const { person, fields } = googleContactsPerson.buildPerson({ input, current }); + try { + return await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.PATCH, + path: `/${resourceName}:updateContact`, + queryParams: { + updatePersonFields: fields.join(','), + personFields: googleContactsApi.contactReadMask.join(','), + }, + body: { etag, ...person }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Update Contact Fields', + }); + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/update-contact-group.ts b/packages/pieces/community/google-contacts/src/lib/action/update-contact-group.ts new file mode 100644 index 000000000000..8582ab92e39c --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/update-contact-group.ts @@ -0,0 +1,79 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { googleContactsGroup } from '../common/contact-group'; +import { updateContactGroupOutputSchema } from '../output-schemas'; + +export const googleContactsUpdateContactGroupAction = createAction({ + auth: googleContactsAuth, + name: 'update_contact_group', + classification: 'WRITE', + displayName: 'Update Contact Group', + description: 'Rename an existing user-created contact group.', + audience: 'ai', + aiMetadata: { + description: + 'Renames one user-created Google Contacts group identified by its opaque resourceName (contactGroups/{id}). It reads the group first to resolve the required etag and sends an explicit name-only field mask, so members and other group data are untouched. Resolve the resourceName with List Contact Groups; Google-managed system groups cannot be renamed. Safe to retry: re-applying the same name converges, and a concurrent edit surfaces as a re-read-and-retry error.', + idempotent: true, + }, + outputSchema: updateContactGroupOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact group resource name such as contactGroups/abc123. Resolve it with List Contact Groups.', + required: true, + }), + name: Property.ShortText({ + displayName: 'New Group Name', + required: true, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + const name = context.propsValue.name.trim(); + if (name.length === 0) { + throw new Error('New Group Name must not be empty.'); + } + const groupFields = googleContactsApi.contactGroupFields.join(','); + let current: Record; + try { + current = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.GET, + path: `/${resourceName}`, + queryParams: { groupFields, maxMembers: '0' }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Update Contact Group (reading the group)', + }); + } + const etag = googleContactsApi.readString({ source: current, path: ['etag'] }); + if (etag === undefined) { + throw new Error( + 'Update Contact Group could not read the current etag of the group; re-read the group and retry.' + ); + } + try { + const response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.PUT, + path: `/${resourceName}`, + body: { + contactGroup: { etag, name }, + updateGroupFields: 'name', + readGroupFields: groupFields, + }, + }); + return googleContactsGroup.summarizeGroup({ group: response }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Update Contact Group', + }); + } + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/update-contact-photo.ts b/packages/pieces/community/google-contacts/src/lib/action/update-contact-photo.ts new file mode 100644 index 000000000000..f34f77f38993 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/action/update-contact-photo.ts @@ -0,0 +1,75 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleContactsAuth } from '../auth'; +import { googleContactsApi } from '../common'; +import { updateContactPhotoOutputSchema } from '../output-schemas'; + +const base64Marker = 'base64,'; + +export const googleContactsUpdateContactPhotoAction = createAction({ + auth: googleContactsAuth, + name: 'update_contact_photo', + classification: 'WRITE', + displayName: 'Update Contact Photo', + description: 'Set the photo of an existing contact from base64 image bytes.', + audience: 'ai', + aiMetadata: { + description: + 'Replaces the photo of one Google Contacts person with the supplied base64-encoded JPEG, PNG or WebP image. Supply the opaque resourceName from List Contacts or Search Contacts; use Delete Contact Photo to remove a photo instead. Safe to retry — re-sending the same bytes converges on the same photo.', + idempotent: true, + }, + outputSchema: updateContactPhotoOutputSchema, + props: { + resourceName: Property.ShortText({ + displayName: 'Resource Name', + description: + 'Opaque contact resource name such as people/c12345. Resolve it with List Contacts or Search Contacts.', + required: true, + }), + photoBytes: Property.LongText({ + displayName: 'Photo Bytes', + description: + 'Base64-encoded JPEG, PNG or WebP image. A data URL prefix is accepted and stripped.', + required: true, + }), + }, + async run(context) { + const resourceName = context.propsValue.resourceName.trim(); + const supplied = context.propsValue.photoBytes.trim(); + const markerIndex = supplied.indexOf(base64Marker); + const photoBytes = + markerIndex === -1 + ? supplied + : supplied.slice(markerIndex + base64Marker.length); + if (photoBytes.length === 0) { + throw new Error('Update Contact Photo needs base64-encoded image bytes.'); + } + let response: Record; + try { + response = await googleContactsApi.sendRequest({ + accessToken: context.auth.access_token, + method: HttpMethod.PATCH, + path: `/${resourceName}:updateContactPhoto`, + body: { + photoBytes, + personFields: googleContactsApi.contactReadMask.join(','), + }, + }); + } catch (error) { + throw googleContactsApi.toApiError({ + error, + operation: 'Update Contact Photo', + }); + } + const person = googleContactsApi.readRecord({ + source: response, + path: ['person'], + }); + if (person === undefined) { + throw new Error( + 'Update Contact Photo could not read the updated contact back from Google Contacts; re-read the contact with Get Contact to confirm the photo was set.' + ); + } + return person; + }, +}); diff --git a/packages/pieces/community/google-contacts/src/lib/action/update-contact.ts b/packages/pieces/community/google-contacts/src/lib/action/update-contact.ts index 3d7e75ec0d79..b6c78343740f 100644 --- a/packages/pieces/community/google-contacts/src/lib/action/update-contact.ts +++ b/packages/pieces/community/google-contacts/src/lib/action/update-contact.ts @@ -18,7 +18,7 @@ export const googleContactsUpdateContactAction = createAction({ name: 'update_contact', classification: 'WRITE', description: 'Update a contact in Google Contacts account.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates fields (name, email, phone, company, job title) on an existing Google Contacts person identified by its resourceName (people/{id}). Use when an agent needs to modify a known contact; requires both the resourceName and the current etag, and a field mask listing which fields to update — the call is rejected if the contact changed since the etag was fetched. Repeating the same update with the same etag/values is safe and yields the same result.', idempotent: true }, displayName: 'Update Contact', outputSchema: updateContactOutputSchema, diff --git a/packages/pieces/community/google-contacts/src/lib/common/contact-group.ts b/packages/pieces/community/google-contacts/src/lib/common/contact-group.ts new file mode 100644 index 000000000000..50d370388d46 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/common/contact-group.ts @@ -0,0 +1,43 @@ +import { googleContactsApi } from './index'; + +function summarizeGroup({ group }: { group: unknown }): Record { + const memberCount = googleContactsApi.readValue({ + source: group, + path: ['memberCount'], + }); + return { + resourceName: googleContactsApi.readString({ + source: group, + path: ['resourceName'], + }), + etag: googleContactsApi.readString({ source: group, path: ['etag'] }), + name: googleContactsApi.readString({ source: group, path: ['name'] }), + formattedName: googleContactsApi.readString({ + source: group, + path: ['formattedName'], + }), + groupType: googleContactsApi.readString({ source: group, path: ['groupType'] }), + memberCount: typeof memberCount === 'number' ? memberCount : 0, + memberResourceNames: googleContactsApi + .readArray({ source: group, path: ['memberResourceNames'] }) + .filter((value): value is string => typeof value === 'string'), + }; +} + +function matchesName({ group, name }: { group: unknown; name: string }): boolean { + const target = name.trim().toLowerCase(); + const groupName = googleContactsApi.readString({ source: group, path: ['name'] }); + const formatted = googleContactsApi.readString({ + source: group, + path: ['formattedName'], + }); + return ( + groupName?.trim().toLowerCase() === target || + formatted?.trim().toLowerCase() === target + ); +} + +export const googleContactsGroup = { + summarizeGroup, + matchesName, +}; diff --git a/packages/pieces/community/google-contacts/src/lib/common/index.ts b/packages/pieces/community/google-contacts/src/lib/common/index.ts index 4532fbae00cd..ae87ea58750c 100644 --- a/packages/pieces/community/google-contacts/src/lib/common/index.ts +++ b/packages/pieces/community/google-contacts/src/lib/common/index.ts @@ -1,3 +1,343 @@ +import { + AuthenticationType, + HttpMethod, + HttpRequest, + httpClient, +} from '@activepieces/pieces-common'; + +const apiRoot = 'https://people.googleapis.com/v1'; + +const contactReadMask = [ + 'names', + 'nicknames', + 'emailAddresses', + 'phoneNumbers', + 'organizations', + 'addresses', + 'biographies', + 'urls', + 'memberships', + 'photos', + 'metadata', +]; + +const contactGroupFields = [ + 'clientData', + 'groupType', + 'memberCount', + 'metadata', + 'name', +]; + +const updatablePersonFields = [ + 'addresses', + 'biographies', + 'birthdays', + 'calendarUrls', + 'clientData', + 'emailAddresses', + 'events', + 'externalIds', + 'genders', + 'imClients', + 'interests', + 'locales', + 'locations', + 'memberships', + 'miscKeywords', + 'names', + 'nicknames', + 'occupations', + 'organizations', + 'phoneNumbers', + 'relations', + 'sipAddresses', + 'urls', + 'userDefined', +]; + +const systemGroupsRejectingAdds = [ + 'contactGroups/all', + 'contactGroups/blocked', + 'contactGroups/chatBuddies', + 'contactGroups/coworkers', + 'contactGroups/family', + 'contactGroups/friends', +]; + +function buildUrl({ + path, + queryParams, + repeatedQueryParams, +}: { + path: string; + queryParams?: Record; + repeatedQueryParams?: Record; +}): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(queryParams ?? {})) { + if (value !== undefined) { + search.append(key, value); + } + } + for (const [key, values] of Object.entries(repeatedQueryParams ?? {})) { + for (const value of values) { + search.append(key, value); + } + } + const query = search.toString(); + return query.length > 0 ? `${apiRoot}${path}?${query}` : `${apiRoot}${path}`; +} + +function readValue({ source, path }: { source: unknown; path: string[] }): unknown { + let current: unknown = source; + for (const key of path) { + if (typeof current !== 'object' || current === null || !(key in current)) { + return undefined; + } + current = Reflect.get(current, key); + } + return current; +} + +function readString({ + source, + path, +}: { + source: unknown; + path: string[]; +}): string | undefined { + const value = readValue({ source, path }); + return typeof value === 'string' ? value : undefined; +} + +function readRecord({ + source, + path, +}: { + source: unknown; + path: string[]; +}): Record | undefined { + const value = readValue({ source, path }); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + return Object.fromEntries(Object.entries(value)); +} + +function readArray({ source, path }: { source: unknown; path: string[] }): unknown[] { + const value = readValue({ source, path }); + return Array.isArray(value) ? value : []; +} + +function parseStringList({ value, label }: { value: string; label: string }): unknown[] { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + throw new Error(`${label} must be a list of strings.`); + } + throw new Error(`${label} must be a list of strings.`); +} + +function toStringList({ value, label }: { value: unknown; label: string }): string[] { + if (value === undefined || value === null) { + return []; + } + const entries = + typeof value === 'string' ? parseStringList({ value, label }) : value; + if (!Array.isArray(entries)) { + throw new Error(`${label} must be a list of strings.`); + } + return entries.map((item) => { + if (typeof item !== 'string' || item.trim().length === 0) { + throw new Error(`${label} must be a list of non-empty strings.`); + } + return item.trim(); + }); +} + +function assertBatchSize({ + values, + max, + label, +}: { + values: string[]; + max: number; + label: string; +}): void { + if (values.length === 0) { + throw new Error(`${label} must contain at least one entry.`); + } + if (values.length > max) { + throw new Error( + `${label} accepts at most ${max} entries, received ${values.length}.` + ); + } +} + +function describeStatus({ status }: { status: unknown }): string { + const message = readString({ source: status, path: ['message'] }); + const code = readValue({ source: status, path: ['code'] }); + if (message !== undefined) { + return message; + } + if (typeof code === 'number') { + return `Google returned status code ${code}.`; + } + return 'Google returned an unspecified failure for this item.'; +} + +function isFailedItem({ response }: { response: unknown }): boolean { + const status = readValue({ source: response, path: ['status'] }); + if (status === undefined || status === null) { + return false; + } + const code = readValue({ source: status, path: ['code'] }); + const message = readValue({ source: status, path: ['message'] }); + if (typeof code === 'number' && code !== 0) { + return true; + } + return typeof message === 'string' && message.length > 0; +} + +function splitItemResponses({ + items, + payloadKey, +}: { + items: { resourceName: string; response: unknown }[]; + payloadKey: string; +}): { + succeeded: { resourceName: string; payload: unknown }[]; + failed: { resourceName: string; status: string }[]; +} { + const succeeded = items + .filter((item) => !isFailedItem({ response: item.response })) + .map((item) => ({ + resourceName: + readString({ + source: item.response, + path: [payloadKey, 'resourceName'], + }) ?? item.resourceName, + payload: readValue({ source: item.response, path: [payloadKey] }), + })); + const failed = items + .filter((item) => isFailedItem({ response: item.response })) + .map((item) => ({ + resourceName: item.resourceName, + status: describeStatus({ + status: readValue({ source: item.response, path: ['status'] }), + }), + })); + return { succeeded, failed }; +} + +function toApiError({ + error, + operation, +}: { + error: unknown; + operation: string; +}): Error { + const status = readValue({ source: error, path: ['response', 'status'] }); + const reason = readString({ + source: error, + path: ['response', 'body', 'error', 'status'], + }); + const message = readString({ + source: error, + path: ['response', 'body', 'error', 'message'], + }); + const suffix = message === undefined ? '' : ` ${message}`; + if (status === 400 && reason === 'FAILED_PRECONDITION') { + return new Error( + `${operation} failed because the record changed since it was read. Re-read the record to get a fresh etag and retry.${suffix}` + ); + } + if (status === 400) { + return new Error( + `${operation} was rejected as invalid by Google Contacts.${suffix}` + ); + } + if (status === 401) { + return new Error( + `${operation} failed: the Google Contacts connection is no longer authorized. Reconnect the account.${suffix}` + ); + } + if (status === 403) { + return new Error( + `${operation} failed: the connected Google account is not allowed to perform it, or the connection is missing the required scope.${suffix}` + ); + } + if (status === 404) { + return new Error( + `${operation} failed: the resource was not found. Resource names are opaque, so resolve a current one with the matching list or search action.${suffix}` + ); + } + if (status === 409) { + return new Error(`${operation} failed: the resource already exists.${suffix}`); + } + if (status === 429) { + return new Error( + `${operation} failed: Google Contacts rate limit reached. Retry later.${suffix}` + ); + } + return new Error(`${operation} failed.${suffix}`); +} + +function isConflict({ error }: { error: unknown }): boolean { + return readValue({ source: error, path: ['response', 'status'] }) === 409; +} + +async function sendRequest({ + accessToken, + method, + path, + queryParams, + repeatedQueryParams, + body, +}: { + accessToken: string; + method: HttpMethod; + path: string; + queryParams?: Record; + repeatedQueryParams?: Record; + body?: Record; +}): Promise> { + const request: HttpRequest> = { + method, + url: buildUrl({ path, queryParams, repeatedQueryParams }), + body, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: accessToken, + }, + }; + const response = await httpClient.sendRequest>(request); + return response.body ?? {}; +} + export const googleContactsCommon = { baseUrl: `https://people.googleapis.com/v1/people`, }; + +export const googleContactsApi = { + contactReadMask, + contactGroupFields, + updatablePersonFields, + systemGroupsRejectingAdds, + sendRequest, + toApiError, + isConflict, + readValue, + readString, + readRecord, + readArray, + toStringList, + assertBatchSize, + splitItemResponses, + describeStatus, + isFailedItem, +}; diff --git a/packages/pieces/community/google-contacts/src/lib/common/person.ts b/packages/pieces/community/google-contacts/src/lib/common/person.ts new file mode 100644 index 000000000000..289f1ddb00c5 --- /dev/null +++ b/packages/pieces/community/google-contacts/src/lib/common/person.ts @@ -0,0 +1,157 @@ +import { googleContactsApi } from './index'; + +function mergeFirstEntry({ + current, + group, + overrides, + defaults, +}: { + current: Record | undefined; + group: string; + overrides: Record; + defaults?: Record; +}): unknown[] { + const entries = googleContactsApi.readArray({ source: current, path: [group] }); + const head = entries[0]; + const base = + typeof head === 'object' && head !== null && !Array.isArray(head) + ? Object.fromEntries(Object.entries(head)) + : {}; + return [{ ...defaults, ...base, ...overrides }, ...entries.slice(1)]; +} + +function buildPerson({ + input, + current, +}: { + input: ContactInput; + current?: Record; +}): { person: Record; fields: string[] } { + const person: Record = {}; + const fields: string[] = []; + + const nameSupplied = + input.firstName !== undefined || + input.middleName !== undefined || + input.lastName !== undefined; + if (nameSupplied) { + person['names'] = mergeFirstEntry({ + current, + group: 'names', + overrides: { + ...(input.firstName === undefined ? {} : { givenName: input.firstName }), + ...(input.middleName === undefined ? {} : { middleName: input.middleName }), + ...(input.lastName === undefined ? {} : { familyName: input.lastName }), + }, + }); + fields.push('names'); + } + + if (input.nickname !== undefined) { + person['nicknames'] = mergeFirstEntry({ + current, + group: 'nicknames', + overrides: { value: input.nickname }, + }); + fields.push('nicknames'); + } + + if (input.clearEmails === true) { + person['emailAddresses'] = []; + fields.push('emailAddresses'); + } else if (input.emails !== undefined && input.emails.length > 0) { + person['emailAddresses'] = input.emails.map((value) => ({ value })); + fields.push('emailAddresses'); + } + + if (input.clearPhoneNumbers === true) { + person['phoneNumbers'] = []; + fields.push('phoneNumbers'); + } else if (input.phoneNumbers !== undefined && input.phoneNumbers.length > 0) { + person['phoneNumbers'] = input.phoneNumbers.map((value) => ({ value })); + fields.push('phoneNumbers'); + } + + const organizationSupplied = + input.company !== undefined || input.jobTitle !== undefined; + if (organizationSupplied) { + person['organizations'] = mergeFirstEntry({ + current, + group: 'organizations', + overrides: { + ...(input.company === undefined ? {} : { name: input.company }), + ...(input.jobTitle === undefined ? {} : { title: input.jobTitle }), + }, + }); + fields.push('organizations'); + } + + if (input.biography !== undefined) { + person['biographies'] = mergeFirstEntry({ + current, + group: 'biographies', + overrides: { value: input.biography }, + defaults: { contentType: 'TEXT_PLAIN' }, + }); + fields.push('biographies'); + } + + return { person, fields }; +} + +function valuesOf({ + person, + group, +}: { + person: unknown; + group: string; +}): string[] { + const entries = googleContactsApi.readArray({ source: person, path: [group] }); + const values = entries.map((entry) => + googleContactsApi.readString({ source: entry, path: ['value'] }) + ); + return values.filter((value): value is string => value !== undefined); +} + +function summarizePerson({ person }: { person: unknown }): Record { + const name = googleContactsApi.readArray({ source: person, path: ['names'] })[0]; + const organization = googleContactsApi.readArray({ + source: person, + path: ['organizations'], + })[0]; + const source = googleContactsApi.readArray({ + source: person, + path: ['metadata', 'sources'], + })[0]; + return { + resourceName: googleContactsApi.readString({ source: person, path: ['resourceName'] }), + etag: googleContactsApi.readString({ source: person, path: ['etag'] }), + displayName: googleContactsApi.readString({ source: name, path: ['displayName'] }), + givenName: googleContactsApi.readString({ source: name, path: ['givenName'] }), + familyName: googleContactsApi.readString({ source: name, path: ['familyName'] }), + emails: valuesOf({ person, group: 'emailAddresses' }), + phoneNumbers: valuesOf({ person, group: 'phoneNumbers' }), + company: googleContactsApi.readString({ source: organization, path: ['name'] }), + jobTitle: googleContactsApi.readString({ source: organization, path: ['title'] }), + updateTime: googleContactsApi.readString({ source: source, path: ['updateTime'] }), + }; +} + +export const googleContactsPerson = { + buildPerson, + summarizePerson, +}; + +export type ContactInput = { + firstName?: string; + middleName?: string; + lastName?: string; + nickname?: string; + emails?: string[]; + phoneNumbers?: string[]; + clearEmails?: boolean; + clearPhoneNumbers?: boolean; + company?: string; + jobTitle?: string; + biography?: string; +}; diff --git a/packages/pieces/community/google-contacts/src/lib/output-schemas.ts b/packages/pieces/community/google-contacts/src/lib/output-schemas.ts index 7576a5c92571..988b789e9851 100644 --- a/packages/pieces/community/google-contacts/src/lib/output-schemas.ts +++ b/packages/pieces/community/google-contacts/src/lib/output-schemas.ts @@ -1,9 +1,5 @@ import { OutputSchema } from '@activepieces/pieces-framework'; -// Google People returns every contact detail as a repeated field: a contact can hold several -// names, emails, phone numbers and organizations, each carrying its own `metadata`. Only the -// `metadata.primary` flag is surfaced from that block -- the rest (source ids, per-field etags) -// is server bookkeeping that says nothing about the contact. const nameFields: OutputSchema['fields'] = [ { key: 'displayName', label: 'Display Name' }, { key: 'givenName', label: 'First Name' }, @@ -21,11 +17,21 @@ const emailFields: OutputSchema['fields'] = [ const phoneFields: OutputSchema['fields'] = [ { key: 'value', label: 'Phone Number' }, - // Google normalises to E.164 here; the raw `value` keeps whatever the user typed. { key: 'canonicalForm', label: 'Canonical Form' }, { key: 'primary', label: 'Primary', value: 'metadata.primary', format: 'boolean' }, ]; +const nicknameFields: OutputSchema['fields'] = [ + { key: 'value', label: 'Nickname' }, + { key: 'primary', label: 'Primary', value: 'metadata.primary', format: 'boolean' }, +]; + +const biographyFields: OutputSchema['fields'] = [ + { key: 'value', label: 'Notes' }, + { key: 'contentType', label: 'Content Type' }, + { key: 'primary', label: 'Primary', value: 'metadata.primary', format: 'boolean' }, +]; + const organizationFields: OutputSchema['fields'] = [ { key: 'name', label: 'Company' }, { key: 'title', label: 'Job Title' }, @@ -34,7 +40,6 @@ const organizationFields: OutputSchema['fields'] = [ const photoFields: OutputSchema['fields'] = [ { key: 'url', label: 'Photo URL', format: 'image' }, - // True when this is Google's generated placeholder rather than a photo the contact has. { key: 'default', label: 'Is Placeholder', format: 'boolean' }, { key: 'primary', label: 'Primary', value: 'metadata.primary', format: 'boolean' }, ]; @@ -61,18 +66,13 @@ const metadataFields: OutputSchema['fields'] = [ listItems: [ { key: 'type', label: 'Source Type' }, { key: 'id', label: 'Source ID' }, - // The only timestamp on a contact, and what the polling trigger orders and dedupes on. { key: 'updateTime', label: 'Last Updated', format: 'datetime' }, ], }, ]; -// One person record. Shared by every action and the trigger, because People returns the same -// shape whether the contact was just created, just updated, matched by a search, or polled. const personFields: OutputSchema['fields'] = [ - // `people/{person_id}` -- the id Update Contact requires. { key: 'resourceName', label: 'Resource Name' }, - // Required alongside resourceName on update; the call is rejected if the contact moved on. { key: 'etag', label: 'ETag' }, { key: 'names', label: 'Names', labelKey: 'displayName', listItems: nameFields }, { @@ -87,6 +87,8 @@ const personFields: OutputSchema['fields'] = [ labelKey: 'value', listItems: phoneFields, }, + { key: 'nicknames', label: 'Nicknames', labelKey: 'value', listItems: nicknameFields }, + { key: 'biographies', label: 'Notes', labelKey: 'value', listItems: biographyFields }, { key: 'organizations', label: 'Organizations', @@ -97,14 +99,51 @@ const personFields: OutputSchema['fields'] = [ { key: 'memberships', label: 'Group Memberships', - // labelKey resolves by dot path against the raw item, so it has to name the nested - // property rather than the flattened key used in membershipFields. labelKey: 'contactGroupMembership.contactGroupId', listItems: membershipFields, }, { key: 'metadata', label: 'Metadata', children: metadataFields }, ]; +const contactSummaryFields: OutputSchema['fields'] = [ + { key: 'resourceName', label: 'Resource Name' }, + { key: 'etag', label: 'ETag' }, + { key: 'displayName', label: 'Display Name' }, + { key: 'givenName', label: 'First Name' }, + { key: 'familyName', label: 'Last Name' }, + { key: 'emails', label: 'Email Addresses' }, + { key: 'phoneNumbers', label: 'Phone Numbers' }, + { key: 'company', label: 'Company' }, + { key: 'jobTitle', label: 'Job Title' }, + { key: 'updateTime', label: 'Last Updated', format: 'datetime' }, +]; + +const contactGroupSummaryFields: OutputSchema['fields'] = [ + { key: 'resourceName', label: 'Resource Name' }, + { key: 'etag', label: 'ETag' }, + { key: 'name', label: 'Name' }, + { key: 'formattedName', label: 'Formatted Name' }, + { key: 'groupType', label: 'Group Type' }, + { key: 'memberCount', label: 'Member Count', format: 'number' }, + { key: 'memberResourceNames', label: 'Member Resource Names' }, +]; + +const batchFailureFields: OutputSchema['fields'] = [ + { key: 'resourceName', label: 'Resource Name' }, + { key: 'status', label: 'Failure Reason' }, +]; + +const batchUpdateFailureFields: OutputSchema['fields'] = [ + { key: 'resourceName', label: 'Resource Name' }, + { key: 'status', label: 'Failure Reason' }, + { key: 'updateMask', label: 'Field Groups Not Applied' }, +]; + +const batchCreateFailureFields: OutputSchema['fields'] = [ + { key: 'requestIndex', label: 'Request Index', format: 'number' }, + { key: 'status', label: 'Failure Reason' }, +]; + export const addContactOutputSchema: OutputSchema = { fields: personFields, }; @@ -113,9 +152,6 @@ export const updateContactOutputSchema: OutputSchema = { fields: personFields, }; -// searchContacts wraps each hit as `{ person: {...} }` under a `results` array, so the person -// fields sit one level deeper than they do on the other three steps. Which of them are actually -// populated follows the caller's Read Mask -- the default is names and email addresses only. export const searchContactsOutputSchema: OutputSchema = { fields: [ { @@ -128,7 +164,209 @@ export const searchContactsOutputSchema: OutputSchema = { ], }; -// The polling trigger emits one person per run, so its payload is a person record directly. export const newOrUpdatedContactOutputSchema: OutputSchema = { fields: personFields, }; + +export const createContactOutputSchema: OutputSchema = { + fields: personFields, +}; + +export const getContactOutputSchema: OutputSchema = { + fields: personFields, +}; + +export const updateContactFieldsOutputSchema: OutputSchema = { + fields: personFields, +}; + +export const listContactsOutputSchema: OutputSchema = { + fields: [ + { + key: 'contacts', + label: 'Contacts', + labelKey: 'displayName', + listItems: contactSummaryFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + ], +}; + +export const searchContactsAtomicOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Matching Contacts', + labelKey: 'displayName', + listItems: contactSummaryFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const deleteContactOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'resourceName', label: 'Resource Name' }, + ], +}; + +export const batchDeleteContactsOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'resourceNames', label: 'Resource Names' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const batchGetContactsOutputSchema: OutputSchema = { + fields: [ + { + key: 'succeeded', + label: 'Contacts Read', + labelKey: 'contact.displayName', + listItems: [ + { key: 'resourceName', label: 'Resource Name' }, + { key: 'contact', label: 'Contact', children: contactSummaryFields }, + { key: 'person', label: 'Full Person', children: personFields }, + ], + }, + { + key: 'failed', + label: 'Failures', + labelKey: 'resourceName', + listItems: batchFailureFields, + }, + { key: 'succeededCount', label: 'Succeeded Count', format: 'number' }, + { key: 'failedCount', label: 'Failed Count', format: 'number' }, + ], +}; + +export const batchCreateContactsOutputSchema: OutputSchema = { + fields: [ + { + key: 'succeeded', + label: 'Contacts Created', + labelKey: 'contact.displayName', + listItems: [ + { key: 'requestIndex', label: 'Request Index', format: 'number' }, + { key: 'resourceName', label: 'Resource Name' }, + { key: 'contact', label: 'Contact', children: contactSummaryFields }, + ], + }, + { + key: 'failed', + label: 'Failures', + labelKey: 'requestIndex', + listItems: batchCreateFailureFields, + }, + { key: 'succeededCount', label: 'Succeeded Count', format: 'number' }, + { key: 'failedCount', label: 'Failed Count', format: 'number' }, + { key: 'requestedCount', label: 'Requested Count', format: 'number' }, + ], +}; + +export const batchUpdateContactsOutputSchema: OutputSchema = { + fields: [ + { + key: 'succeeded', + label: 'Contacts Updated', + labelKey: 'contact.displayName', + listItems: [ + { key: 'resourceName', label: 'Resource Name' }, + { key: 'contact', label: 'Contact', children: contactSummaryFields }, + { key: 'person', label: 'Full Person', children: personFields }, + ], + }, + { + key: 'failed', + label: 'Failures', + labelKey: 'resourceName', + listItems: batchUpdateFailureFields, + }, + { key: 'succeededCount', label: 'Succeeded Count', format: 'number' }, + { key: 'failedCount', label: 'Failed Count', format: 'number' }, + { key: 'updateMasks', label: 'Update Masks' }, + ], +}; + +export const updateContactPhotoOutputSchema: OutputSchema = { + fields: personFields, +}; + +export const deleteContactPhotoOutputSchema: OutputSchema = { + fields: personFields, +}; + +export const listContactGroupsOutputSchema: OutputSchema = { + fields: [ + { + key: 'contactGroups', + label: 'Contact Groups', + labelKey: 'formattedName', + listItems: contactGroupSummaryFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + ], +}; + +export const getContactGroupOutputSchema: OutputSchema = { + fields: contactGroupSummaryFields, +}; + +export const updateContactGroupOutputSchema: OutputSchema = { + fields: contactGroupSummaryFields, +}; + +export const batchGetContactGroupsOutputSchema: OutputSchema = { + fields: [ + { + key: 'succeeded', + label: 'Contact Groups Read', + labelKey: 'formattedName', + listItems: contactGroupSummaryFields, + }, + { + key: 'failed', + label: 'Failures', + labelKey: 'resourceName', + listItems: batchFailureFields, + }, + { key: 'succeededCount', label: 'Succeeded Count', format: 'number' }, + { key: 'failedCount', label: 'Failed Count', format: 'number' }, + ], +}; + +export const createContactGroupOutputSchema: OutputSchema = { + fields: [ + { key: 'created', label: 'Created', format: 'boolean' }, + { + key: 'contactGroup', + label: 'Contact Group', + children: contactGroupSummaryFields, + }, + ], +}; + +export const deleteContactGroupOutputSchema: OutputSchema = { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'resourceName', label: 'Resource Name' }, + { key: 'deletedContacts', label: 'Contacts Also Deleted', format: 'boolean' }, + ], +}; + +export const modifyContactGroupMembersOutputSchema: OutputSchema = { + fields: [ + { key: 'resourceName', label: 'Contact Group Resource Name' }, + { key: 'added', label: 'Added Contacts' }, + { key: 'removed', label: 'Removed Contacts' }, + { key: 'notFoundResourceNames', label: 'Not Found Contacts' }, + { + key: 'canNotRemoveLastContactGroupResourceNames', + label: 'Cannot Remove Last Group', + }, + ], +}; From 1513fb716c826bbaacd802f6152bd99f3a7a96fe Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 22 Sep 2026 14:49:54 +0300 Subject: [PATCH 4/7] fix(flows): opening a step panel no longer slows down as the flow grows (#15708) --- brain/knowledge/pieces-engine/pieces.md | 1 + .../app/flows/flow-run/flow-run-service.ts | 2 +- .../app/flows/step-run/sample-data.service.ts | 49 ++++-- .../src/app/mcp/tools/ap-get-piece-props.ts | 7 +- .../metadata/piece-metadata-controller.ts | 7 +- .../flows/step-run/sample-data-scope.test.ts | 153 ++++++++++++++++++ 6 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts diff --git a/brain/knowledge/pieces-engine/pieces.md b/brain/knowledge/pieces-engine/pieces.md index 688e9612a1cc..d6ba047925f6 100644 --- a/brain/knowledge/pieces-engine/pieces.md +++ b/brain/knowledge/pieces-engine/pieces.md @@ -28,6 +28,7 @@ The metadata catalog of automation integrations ("pieces") — each a named inte - **Piece sorting mutates its input, which is why the list result cannot simply be cached.** `pieceSorting`'s comparators and `reverseIfDesc` sort in place (`pieces.sort(...)`, `pieces.reverse()`). That is safe only because the array is rebuilt per request; making them non-mutating is a prerequisite for ever caching the built list, or requests would reorder shared state under each other. Same trap in `pieceTranslation.translatePiece`: when the piece has no entry for the locale it returns the *original* object rather than a clone, so any `piece.i18n = undefined` after it would mutate the shared row — take the no-translations branch before calling it. - **DynamicProperties clears its value before it knows the new schema, so the merge source must be a snapshot.** `DynamicPropertiesImplementation` re-fetches the child schema on every refresher change, clearing the form value synchronously and re-populating it in the mutation callback. The merge source for `getDefaultValueForProperties` has to be a `lastKnownValue` ref captured *before* the clear — reading `form.getValues()` in the callback sees the cleared `null` and defaults every child (GIT-1514). The snapshot must be spread-cloned: RHF `getValues(name)` hands back the live object and the clear's `setValue(...child, null)` mutates it in place. Guard the ref with `isNil` so it survives rapid successive changes, where later effect runs already observe `null`. - `DynamicPropertiesContext` tracks loading by property name only, so two in-flight requests for the same property let the first completion clear the flag for both — briefly re-enabling Test Step while the value is still cleared. +- **`POST /v1/pieces/options` cost scales with the whole flow's sample data, not with the property being resolved.** The handler builds an `EXECUTE_PROPERTY` job whose payload carries `sampleData` — and `getSampleDataForFlow` reads *every* step's sample-data `file` row. That payload then crosses Postgres → API → Redis → worker → engine socket, so a big flow pays megabytes to resolve a trivial resolver like the HTTP piece's `authFields`: measured 7.3 MB on a 25-step flow, and a panel open fires three of these in parallel (GIT-1822). The engine only uses `sampleData` to seed step outputs so `{{step.x}}` inside the property's `input` resolves, so callers must use `getSampleDataReferencedBy` and pass the request `input` — which narrows the read to the steps that input names. When narrowing, a referenced `LOOP_ON_ITEMS` step also needs whatever its `items` expression references: `stateFromFlowVersion` derives a loop's `item` by resolving its settings against the context built so far, never from `sampleData[loopName]`. Matching step names as substrings of the serialized input over-approximates, which is the safe direction — a step reference always spells the name literally, in both the property-path and script branches of `props-resolver`. - **The frontend `POST /v1/pieces/options` client only rejects for DYNAMIC.** `piecesApi.options` (`packages/web/src/features/pieces/api/`) catches DROPDOWN failures, toasts, and *resolves* with a disabled-dropdown fallback — so for dropdowns every error path wired onto that mutation is dead: `usePieceOptions`' `onError` handlers, its `retry: 1`, and the `if (error) throw error` into `DynamicPropertiesErrorBoundary`. DYNAMIC must rethrow: a swallowed failure arrives as a *successful* empty schema, which resets the property's children to defaults and gets persisted by step-settings autosave. - **An action that can be called as an MCP tool cannot create a waitpoint.** An MCP piece tool runs the real action through `actionRunService`, which sets `actionRunMode` on the engine, and diff --git a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts index 1016bc5af6d0..d9d089a18990 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts @@ -375,7 +375,7 @@ export const flowRunService = (log: FastifyBaseLogger) => ({ platformId: await projectService(log).getPlatformId(projectId), executeTrigger: false, streamStepProgress: StreamStepProgress.WEBSOCKET, - sampleData: !isNil(stepNameToTest) ? await sampleDataService(log).getSampleDataForFlow(projectId, flowVersion, SampleDataFileType.OUTPUT) : undefined, + sampleData: !isNil(stepNameToTest) ? await sampleDataService(log).getSampleDataForFlow({ projectId, flowVersion, type: SampleDataFileType.OUTPUT }) : undefined, }, log) }, async startManualTrigger({ projectId, flowVersionId, triggeredBy }: StartManualTriggerParams): Promise { diff --git a/packages/server/api/src/app/flows/step-run/sample-data.service.ts b/packages/server/api/src/app/flows/step-run/sample-data.service.ts index 1415fef44c7e..8aea4de635a1 100644 --- a/packages/server/api/src/app/flows/step-run/sample-data.service.ts +++ b/packages/server/api/src/app/flows/step-run/sample-data.service.ts @@ -1,5 +1,5 @@ import { apId, FlowId, FlowVersionId, isNil, ProjectId, stringifyNullOrUndefined } from '@activepieces/core-utils' -import { DATA_TYPE_KEY_IN_FILE_METADATA, FileCompression, FileType, FlowAction, flowStructureUtil, FlowTrigger, FlowVersion, SampleDataDataType, SampleDataFileType, SampleDataSettings, SaveSampleDataResponse, Step } from '@activepieces/shared' +import { DATA_TYPE_KEY_IN_FILE_METADATA, FileCompression, FileType, FlowAction, FlowActionType, flowStructureUtil, FlowTrigger, FlowVersion, SampleDataDataType, SampleDataFileType, SampleDataSettings, SaveSampleDataResponse, Step } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' import { fileRepo, fileService } from '../../file/file.service' @@ -55,19 +55,13 @@ export const sampleDataService = (log: FastifyBaseLogger) => ({ type: params.fileType, }).andWhere('metadata->>\'flowId\' = :flowId', { flowId: params.flowId }).execute() }, - async getSampleDataForFlow(projectId: ProjectId, flowVersion: FlowVersion, type: SampleDataFileType): Promise> { - const steps = flowStructureUtil.getAllSteps(flowVersion.trigger) - const sampleDataPromises = steps.map(async (step) => { - const data = await this.getOrReturnEmpty({ - projectId, - flowVersion, - stepName: step.name, - type, - }) - return { [step.name]: data } - }) - const sampleDataArray = await Promise.all(sampleDataPromises) - return Object.assign({}, ...sampleDataArray) + async getSampleDataForFlow({ projectId, flowVersion, type, referencedBy }: GetSampleDataForFlowParams): Promise> { + const allSteps = flowStructureUtil.getAllSteps(flowVersion.trigger) + const steps = isNil(referencedBy) ? allSteps : selectReferencedSteps({ steps: allSteps, referencedBy }) + return Object.fromEntries(await Promise.all(steps.map(async (step) => [ + step.name, + await this.getOrReturnEmpty({ projectId, flowVersion, stepName: step.name, type }), + ]))) }, }) @@ -100,6 +94,21 @@ export async function saveSampleData({ }) } +function selectReferencedSteps({ steps, referencedBy }: SelectReferencedStepsParams): Step[] { + const referenced: Step[] = [] + let references = isNil(referencedBy) ? '' : JSON.stringify(referencedBy) + for (const step of [...steps].reverse()) { + if (!references.includes(step.name)) { + continue + } + referenced.push(step) + if (step.type === FlowActionType.LOOP_ON_ITEMS) { + references += JSON.stringify(step.settings) + } + } + return referenced.reverse() +} + async function useExistingOrCreateNewSampleId(projectId: ProjectId, flowVersion: FlowVersion, step: FlowAction | FlowTrigger, fileType: FileType, log: FastifyBaseLogger): Promise { const sampleDataId = fileType === FileType.SAMPLE_DATA ? step.settings.sampleData?.sampleDataFileId : step.settings.sampleData?.sampleDataInputFileId if (isNil(sampleDataId)) { @@ -118,6 +127,18 @@ async function useExistingOrCreateNewSampleId(projectId: ProjectId, flowVersion: } +type SelectReferencedStepsParams = { + steps: Step[] + referencedBy: unknown +} + +type GetSampleDataForFlowParams = { + projectId: ProjectId + flowVersion: FlowVersion + type: SampleDataFileType + referencedBy?: unknown +} + type DeleteSampleDataForStepParams = { projectId: ProjectId fileId: string diff --git a/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts b/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts index 8b492988762e..138c837c12b9 100644 --- a/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts +++ b/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts @@ -158,7 +158,12 @@ async function resolvePropertyOptions({ props, componentProps, pieceName, pieceV const [piecePackage, sampleData] = await Promise.all([ getPiecePackageWithoutArchive(log, platformId, { pieceName, pieceVersion }), flow - ? sampleDataService(log).getSampleDataForFlow(projectId, flow.version, SampleDataFileType.OUTPUT) + ? sampleDataService(log).getSampleDataForFlow({ + projectId, + flowVersion: flow.version, + type: SampleDataFileType.OUTPUT, + referencedBy: providedInput, + }) : Promise.resolve({} as Record), ]) const flowVersion: FlowVersion | undefined = flow?.version diff --git a/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts b/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts index eeac17ff19e5..8d4760fbb29f 100644 --- a/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts +++ b/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts @@ -138,7 +138,12 @@ const basePiecesController: FastifyPluginAsyncZod = async (app) => { id: req.body.flowId, versionId: req.body.flowVersionId, }) - const sampleData = await sampleDataService(req.log).getSampleDataForFlow(projectId, flow.version, SampleDataFileType.OUTPUT) + const sampleData = await sampleDataService(req.log).getSampleDataForFlow({ + projectId, + flowVersion: flow.version, + type: SampleDataFileType.OUTPUT, + referencedBy: req.body.input, + }) const { response } = await userInteractionWatcher.submitAndWaitForResponse>({ jobType: WorkerJobType.EXECUTE_PROPERTY, platformId: platform.id, diff --git a/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts b/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts new file mode 100644 index 000000000000..3285393076c3 --- /dev/null +++ b/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts @@ -0,0 +1,153 @@ +import { apDayjs } from '@activepieces/server-utils' +import { + FileCompression, + FileType, + FlowAction, + FlowActionType, + FlowTrigger, + FlowTriggerType, + FlowVersion, + SampleDataFileType, +} from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { sampleDataService } from '../../../../../src/app/flows/step-run/sample-data.service' +import { db } from '../../../../helpers/db' +import { createMockFile, createMockFlowVersion, mockAndSaveBasicSetup } from '../../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../../helpers/test-setup' + +let app: FastifyInstance +let projectId: string +let flowVersion: FlowVersion + +beforeAll(async () => { + app = await setupTestEnvironment() + const { mockPlatform, mockProject } = await mockAndSaveBasicSetup() + projectId = mockProject.id + flowVersion = await buildFlowVersion({ projectId, platformId: mockPlatform.id }) +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function saveSampleDataFile({ projectId, platformId, output }: SaveSampleDataFileParams): Promise { + const file = createMockFile({ + projectId, + platformId, + type: FileType.SAMPLE_DATA, + compression: FileCompression.NONE, + data: Buffer.from(JSON.stringify(output)), + }) + await db.save('file', file) + return file.id +} + +function pieceStep({ name, sampleDataFileId, nextAction }: PieceStepParams): FlowAction { + return { + type: FlowActionType.PIECE, + name, + displayName: name, + settings: { + pieceName: '@activepieces/piece-webhook', + pieceVersion: '~0.1.29', + actionName: 'return_response', + input: {}, + propertySettings: {}, + errorHandlingOptions: {}, + sampleData: { sampleDataFileId, lastTestDate: apDayjs().toISOString() }, + }, + valid: true, + lastUpdatedDate: apDayjs().toISOString(), + nextAction, + } +} + +async function buildFlowVersion({ projectId, platformId }: BuildFlowVersionParams): Promise { + const [triggerFileId, firstFileId, secondFileId, unrelatedFileId] = await Promise.all([ + saveSampleDataFile({ projectId, platformId, output: { rows: [1, 2] } }), + saveSampleDataFile({ projectId, platformId, output: { body: 'step_1' } }), + saveSampleDataFile({ projectId, platformId, output: { body: 'step_2' } }), + saveSampleDataFile({ projectId, platformId, output: { body: 'unrelated' } }), + ]) + + const loopStep: FlowAction = { + type: FlowActionType.LOOP_ON_ITEMS, + name: 'loop_1', + displayName: 'loop_1', + settings: { items: '{{trigger.rows}}' }, + valid: true, + lastUpdatedDate: apDayjs().toISOString(), + nextAction: pieceStep({ name: 'unrelated', sampleDataFileId: unrelatedFileId }), + } + + const trigger: FlowTrigger = { + type: FlowTriggerType.PIECE, + name: 'trigger', + displayName: 'trigger', + settings: { + pieceName: '@activepieces/piece-webhook', + pieceVersion: '~0.1.29', + triggerName: 'catch_webhook', + input: {}, + propertySettings: {}, + sampleData: { sampleDataFileId: triggerFileId, lastTestDate: apDayjs().toISOString() }, + }, + valid: true, + lastUpdatedDate: apDayjs().toISOString(), + nextAction: pieceStep({ + name: 'step_1', + sampleDataFileId: firstFileId, + nextAction: pieceStep({ + name: 'step_2', + sampleDataFileId: secondFileId, + nextAction: loopStep, + }), + }), + } + + return createMockFlowVersion({ trigger }) +} + +describe('Sample data scope for property resolution', () => { + it.each([ + ['an input that references no step', { authType: 'basic' }, []], + ['only the step the input references', { url: '{{step_2.body}}' }, ['step_2']], + ['a referenced loop and the step it iterates over', { url: '{{loop_1.item}}' }, ['loop_1', 'trigger']], + ])('reads %s', async (_case, referencedBy, expectedStepNames) => { + const sampleData = await sampleDataService(app.log).getSampleDataForFlow({ + projectId, + flowVersion, + type: SampleDataFileType.OUTPUT, + referencedBy, + }) + + expect(Object.keys(sampleData).sort()).toStrictEqual(expectedStepNames) + }) + + it('still reads every step for a whole-flow read', async () => { + const sampleData = await sampleDataService(app.log).getSampleDataForFlow({ + projectId, + flowVersion, + type: SampleDataFileType.OUTPUT, + }) + + expect(Object.keys(sampleData).sort()).toStrictEqual(['loop_1', 'step_1', 'step_2', 'trigger', 'unrelated']) + }) +}) + +type SaveSampleDataFileParams = { + projectId: string + platformId: string + output: unknown +} + +type PieceStepParams = { + name: string + sampleDataFileId: string + nextAction?: FlowAction +} + +type BuildFlowVersionParams = { + projectId: string + platformId: string +} From 8385e0eb8fec892371f795f0d7bff1154e34418f Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 22 Sep 2026 15:40:15 +0300 Subject: [PATCH 5/7] fix(flows): testing a step no longer waits on the whole flow's sample data (#15713) --- brain/knowledge/pieces-engine/pieces.md | 1 + .../app/flows/flow-run/flow-run-service.ts | 22 ++- .../flows/step-run/sample-data-scope.test.ts | 11 ++ .../ce/flows/step-run/test-step-e2e.test.ts | 179 ++++++++++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 packages/server/api/test/integration/ce/flows/step-run/test-step-e2e.test.ts diff --git a/brain/knowledge/pieces-engine/pieces.md b/brain/knowledge/pieces-engine/pieces.md index d6ba047925f6..711184ffecba 100644 --- a/brain/knowledge/pieces-engine/pieces.md +++ b/brain/knowledge/pieces-engine/pieces.md @@ -29,6 +29,7 @@ The metadata catalog of automation integrations ("pieces") — each a named inte - **DynamicProperties clears its value before it knows the new schema, so the merge source must be a snapshot.** `DynamicPropertiesImplementation` re-fetches the child schema on every refresher change, clearing the form value synchronously and re-populating it in the mutation callback. The merge source for `getDefaultValueForProperties` has to be a `lastKnownValue` ref captured *before* the clear — reading `form.getValues()` in the callback sees the cleared `null` and defaults every child (GIT-1514). The snapshot must be spread-cloned: RHF `getValues(name)` hands back the live object and the clear's `setValue(...child, null)` mutates it in place. Guard the ref with `isNil` so it survives rapid successive changes, where later effect runs already observe `null`. - `DynamicPropertiesContext` tracks loading by property name only, so two in-flight requests for the same property let the first completion clear the flag for both — briefly re-enabling Test Step while the value is still cleared. - **`POST /v1/pieces/options` cost scales with the whole flow's sample data, not with the property being resolved.** The handler builds an `EXECUTE_PROPERTY` job whose payload carries `sampleData` — and `getSampleDataForFlow` reads *every* step's sample-data `file` row. That payload then crosses Postgres → API → Redis → worker → engine socket, so a big flow pays megabytes to resolve a trivial resolver like the HTTP piece's `authFields`: measured 7.3 MB on a 25-step flow, and a panel open fires three of these in parallel (GIT-1822). The engine only uses `sampleData` to seed step outputs so `{{step.x}}` inside the property's `input` resolves, so callers must use `getSampleDataReferencedBy` and pass the request `input` — which narrows the read to the steps that input names. When narrowing, a referenced `LOOP_ON_ITEMS` step also needs whatever its `items` expression references: `stateFromFlowVersion` derives a loop's `item` by resolving its settings against the context built so far, never from `sampleData[loopName]`. Matching step names as substrings of the serialized input over-approximates, which is the safe direction — a step reference always spells the name literally, in both the property-path and script branches of `props-resolver`. +- **There is no static signal for whether a DynamicProperties resolver is pure, so caching its result is never safe by inspection.** Both tempting heuristics have counterexamples in this repo. "Trivial resolver" fails because purity cannot be read off the declaration; "declares no connection" fails because `packages/pieces/core/tables` `update-record` declares `auth: PieceAuth.None()` yet its `values` resolver calls `tablesCommon.createFieldProperties` → `getTableFields`, a live request to `v1/fields` authenticated with the **engine token** rather than a connection — so a column added to an Activepieces Table would not show up. Third-party resolvers that fetch live schema (Zendesk `userFieldsDynamicProp`, Supabase `table_columns`) are the same class reached through a connection. The failure mode is a silently outdated form, which the builder gives the user no way to refresh. Anything in this area (GIT-813) needs either a purity signal the piece declares, or stale-while-revalidate that always refetches and corrects within a round trip — and note that SWR re-applies the schema a second time, which is exactly the path the `lastKnownValue` snapshot above exists to protect. - **The frontend `POST /v1/pieces/options` client only rejects for DYNAMIC.** `piecesApi.options` (`packages/web/src/features/pieces/api/`) catches DROPDOWN failures, toasts, and *resolves* with a disabled-dropdown fallback — so for dropdowns every error path wired onto that mutation is dead: `usePieceOptions`' `onError` handlers, its `retry: 1`, and the `if (error) throw error` into `DynamicPropertiesErrorBoundary`. DYNAMIC must rethrow: a swallowed failure arrives as a *successful* empty schema, which resets the property's children to defaults and gets persisted by step-settings autosave. - **An action that can be called as an MCP tool cannot create a waitpoint.** An MCP piece tool runs the real action through `actionRunService`, which sets `actionRunMode` on the engine, and diff --git a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts index d9d089a18990..ce8560d1069c 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts @@ -1,6 +1,6 @@ import { ActivepiecesError, apId, Cursor, ErrorCode, FlowId, FlowRunId, FlowVersionId, isNil, PlatformId, ProjectId, SeekPage } from '@activepieces/core-utils' import { apDayjs, apDayjsDuration, wideEvent } from '@activepieces/server-utils' -import { ExecuteFlowJobData, ExecutionType, ExecutioOutputFile, FileCompression, FileType, FlowRetryStrategy, FlowRun, FlowRunCountByStatus, FlowRunStatus, FlowRunWithRetryError, FlowVersion, GenericStepOutput, isFlowRunStateTerminal, JobPayload, LATEST_JOB_DATA_SCHEMA_VERSION, logSerializer, LogSliceRef, ResumeReason, RunEnvironment, RunInternalError, SampleDataFileType, StepOutput, StepOutputStatus, StepOutputType, StreamStepProgress, WorkerJobType } from '@activepieces/shared' +import { ExecuteFlowJobData, ExecutionType, ExecutioOutputFile, FileCompression, FileType, FlowRetryStrategy, FlowRun, FlowRunCountByStatus, FlowRunStatus, FlowRunWithRetryError, flowStructureUtil, FlowVersion, GenericStepOutput, isFlowRunStateTerminal, JobPayload, LATEST_JOB_DATA_SCHEMA_VERSION, logSerializer, LogSliceRef, ResumeReason, RunEnvironment, RunInternalError, SampleDataFileType, StepOutput, StepOutputStatus, StepOutputType, StreamStepProgress, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import pLimit from 'p-limit' import { ArrayContains, In, IsNull, Not, Repository, SelectQueryBuilder } from 'typeorm' @@ -375,7 +375,12 @@ export const flowRunService = (log: FastifyBaseLogger) => ({ platformId: await projectService(log).getPlatformId(projectId), executeTrigger: false, streamStepProgress: StreamStepProgress.WEBSOCKET, - sampleData: !isNil(stepNameToTest) ? await sampleDataService(log).getSampleDataForFlow({ projectId, flowVersion, type: SampleDataFileType.OUTPUT }) : undefined, + sampleData: !isNil(stepNameToTest) ? await sampleDataService(log).getSampleDataForFlow({ + projectId, + flowVersion, + type: SampleDataFileType.OUTPUT, + referencedBy: settingsOfStepUnderTest({ flowVersion, stepNameToTest }), + }) : undefined, }, log) }, async startManualTrigger({ projectId, flowVersionId, triggeredBy }: StartManualTriggerParams): Promise { @@ -779,6 +784,19 @@ export function isOutsideRetentionWindow(createdTime: string, retentionDays: num return apDayjs(createdTime).add(retentionDays, 'day').isBefore(apDayjs()) } +function settingsOfStepUnderTest({ flowVersion, stepNameToTest }: SettingsOfStepUnderTestParams): unknown { + const stepUnderTest = flowStructureUtil.getStep(stepNameToTest, flowVersion.trigger) + if (isNil(stepUnderTest)) { + return undefined + } + return flowStructureUtil.getAllChildSteps(stepUnderTest).map((step) => step.settings) +} + +type SettingsOfStepUnderTestParams = { + flowVersion: FlowVersion + stepNameToTest: string +} + type CreateParams = { projectId: ProjectId flowVersionId: FlowVersionId diff --git a/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts b/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts index 3285393076c3..673015c08077 100644 --- a/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts +++ b/packages/server/api/test/integration/ce/flows/step-run/sample-data-scope.test.ts @@ -124,6 +124,17 @@ describe('Sample data scope for property resolution', () => { expect(Object.keys(sampleData).sort()).toStrictEqual(expectedStepNames) }) + it('reads a step under test together with the steps inside it', async () => { + const sampleData = await sampleDataService(app.log).getSampleDataForFlow({ + projectId, + flowVersion, + type: SampleDataFileType.OUTPUT, + referencedBy: [{ items: '{{trigger.rows}}' }, { input: { url: '{{step_1.body}}' } }], + }) + + expect(Object.keys(sampleData).sort()).toStrictEqual(['step_1', 'trigger']) + }) + it('still reads every step for a whole-flow read', async () => { const sampleData = await sampleDataService(app.log).getSampleDataForFlow({ projectId, diff --git a/packages/server/api/test/integration/ce/flows/step-run/test-step-e2e.test.ts b/packages/server/api/test/integration/ce/flows/step-run/test-step-e2e.test.ts new file mode 100644 index 000000000000..a14380978db6 --- /dev/null +++ b/packages/server/api/test/integration/ce/flows/step-run/test-step-e2e.test.ts @@ -0,0 +1,179 @@ +import { apDayjs } from '@activepieces/server-utils' +import { + FileCompression, + FileType, + FlowActionType, + FlowRunStatus, + FlowTriggerType, + FlowVersionState, + PackageType, + PieceType, +} from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { worker } from '../../../../../../worker/src/lib/worker' +import { databaseConnection } from '../../../../../src/app/database/database-connection' +import { flowRunService } from '../../../../../src/app/flows/flow-run/flow-run-service' +import { db } from '../../../../helpers/db' +import { setupE2eEnvironment } from '../../../../helpers/e2e-setup' +import { + createMockFile, + createMockFlow, + createMockFlowVersion, + createMockPieceMetadata, + mockAndSaveBasicSetup, +} from '../../../../helpers/mocks' + +let app: FastifyInstance + +beforeAll(async () => { + const ctx = await setupE2eEnvironment() + app = ctx.app + await worker.start({ + apiUrl: ctx.apiUrl, + socketUrl: { url: ctx.apiUrl, path: '/api/socket.io' }, + workerToken: ctx.workerToken, + }) + await new Promise((resolve) => setTimeout(resolve, 5000)) +}, 30_000) + +afterAll(async () => { + await worker.stop() + await app.close() +}, 15_000) + +async function saveSampleDataFile({ projectId, platformId, output }: { projectId: string, platformId: string, output: unknown }): Promise { + const file = createMockFile({ + projectId, + platformId, + type: FileType.SAMPLE_DATA, + compression: FileCompression.NONE, + data: Buffer.from(JSON.stringify(output)), + }) + await db.save('file', file) + return file.id +} + +async function pollFlowRunToCompletion({ flowRunId, projectId }: { flowRunId: string, projectId: string }) { + const deadline = Date.now() + 120_000 + let result = await flowRunService(app.log).getOnePopulatedOrThrow({ id: flowRunId, projectId }) + while ( + (result.status === FlowRunStatus.QUEUED || result.status === FlowRunStatus.RUNNING) && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 500)) + result = await flowRunService(app.log).getOnePopulatedOrThrow({ id: flowRunId, projectId }) + } + return result +} + +describe('Test step with narrowed sample data', () => { + it('resolves a reference to an earlier step through the full worker round trip', async () => { + const { mockPlatform, mockProject } = await mockAndSaveBasicSetup() + + const triggerFileId = await saveSampleDataFile({ + projectId: mockProject.id, + platformId: mockPlatform.id, + output: { unreferenced: 'trigger payload the tested step never mentions' }, + }) + const firstStepFileId = await saveSampleDataFile({ + projectId: mockProject.id, + platformId: mockPlatform.id, + output: { value: 'from-step-1' }, + }) + + const mockPiece = createMockPieceMetadata({ + name: '@activepieces/piece-webhook', + version: '0.1.29', + platformId: undefined, + packageType: PackageType.REGISTRY, + pieceType: PieceType.OFFICIAL, + }) + await databaseConnection().getRepository('piece_metadata').save(mockPiece) + + const mockFlow = createMockFlow({ projectId: mockProject.id }) + await db.save('flow', mockFlow) + + const stepUnderTest = { + type: FlowActionType.CODE, + name: 'step_2', + displayName: 'Echo', + valid: true, + lastUpdatedDate: apDayjs().toISOString(), + settings: { + sourceCode: { + code: 'export const code = async (inputs) => ({ echoed: inputs.value });', + packageJson: '{}', + }, + input: { value: '{{step_1[\'output\'].value}}' }, + errorHandlingOptions: {}, + }, + } + + const mockFlowVersion = createMockFlowVersion({ + flowId: mockFlow.id, + state: FlowVersionState.DRAFT, + trigger: { + type: FlowTriggerType.PIECE, + name: 'trigger', + displayName: 'Catch Webhook', + valid: true, + lastUpdatedDate: apDayjs().toISOString(), + settings: { + pieceName: '@activepieces/piece-webhook', + pieceVersion: '~0.1.29', + triggerName: 'catch_webhook', + input: {}, + propertySettings: {}, + sampleData: { sampleDataFileId: triggerFileId, lastTestDate: apDayjs().toISOString() }, + }, + nextAction: { + type: FlowActionType.CODE, + name: 'step_1', + displayName: 'Produce Value', + valid: true, + lastUpdatedDate: apDayjs().toISOString(), + settings: { + sourceCode: { code: 'export const code = async () => ({ value: \'never runs\' });', packageJson: '{}' }, + input: {}, + errorHandlingOptions: {}, + sampleData: { sampleDataFileId: firstStepFileId, lastTestDate: apDayjs().toISOString() }, + }, + nextAction: stepUnderTest, + }, + }, + }) + await db.save('flow_version', mockFlowVersion) + + const flowRun = await flowRunService(app.log).test({ + projectId: mockProject.id, + flowVersionId: mockFlowVersion.id, + stepNameToTest: 'step_2', + triggeredBy: undefined, + }) + + const result = await pollFlowRunToCompletion({ flowRunId: flowRun.id, projectId: mockProject.id }) + + expect(result.status).toBe(FlowRunStatus.SUCCEEDED) + expect(result.steps?.['step_2']?.output).toStrictEqual({ echoed: 'from-step-1' }) + }, 180_000) + + it('still queues a tracked run when the step under test no longer exists', async () => { + const { mockProject } = await mockAndSaveBasicSetup() + const mockFlow = createMockFlow({ projectId: mockProject.id }) + await db.save('flow', mockFlow) + const mockFlowVersion = createMockFlowVersion({ + flowId: mockFlow.id, + state: FlowVersionState.DRAFT, + }) + await db.save('flow_version', mockFlowVersion) + + const flowRun = await flowRunService(app.log).test({ + projectId: mockProject.id, + flowVersionId: mockFlowVersion.id, + stepNameToTest: 'deleted_step', + triggeredBy: undefined, + }) + + expect(flowRun.id).toBeDefined() + }, 180_000) +}) From dbc13dc067cf3b4af3b7a0d1f6b0f5c1deb2662e Mon Sep 17 00:00:00 2001 From: Amr Elmohamady Date: Tue, 22 Sep 2026 16:38:49 +0300 Subject: [PATCH 6/7] feat(workers): emit structured job.failed log for queue alerting (#15541) Co-authored-by: Claude Opus 4.7 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- brain/knowledge/execution-runtime/workers.md | 2 +- .../src/app/flows/flow-run/flow-runs-queue.ts | 15 +++++ .../src/app/helper/system-jobs/system-job.ts | 10 +++- .../src/app/workers/job-queue/job-broker.ts | 27 ++++++++- .../workers/job-queue/job-failure-logger.ts | 55 +++++++++++++++++++ .../job-queue/job-failure-logger.test.ts | 55 +++++++++++++++++++ 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 packages/server/api/src/app/workers/job-queue/job-failure-logger.ts create mode 100644 packages/server/api/test/unit/app/workers/job-queue/job-failure-logger.test.ts diff --git a/brain/knowledge/execution-runtime/workers.md b/brain/knowledge/execution-runtime/workers.md index d31d32b7247b..4247e53dfb3b 100644 --- a/brain/knowledge/execution-runtime/workers.md +++ b/brain/knowledge/execution-runtime/workers.md @@ -77,7 +77,7 @@ Whole-fleet throughput can collapse while every worker is healthy and idle, beca **Diagnostic signature** (how it was found 2026-07-25/26), all from ClickHouse `default.otel_logs`: worker fleet constant (`uniqExact(LogAttributes['host'])` on `event='system.snapshot'`), per-job latency flat or improving (`avg(executionMs)` over `job.execute`) so workers aren't the bottleneck, and app-side dequeues falling on the same curve as executions. The decisive one is **uniform stretch**: for `EXECUTE_POLLING`, group by hour and compute `count() / uniqExact(flowVersion.id)`. Flat `distinctTriggers` with decaying fires-per-trigger (13,050 triggers steady while each went 15.2/hr → 1.05/hr over 30h) means a shared dispatcher throttling all of them equally; triggers actually dying looks the opposite — `distinctTriggers` drops, fires-per-trigger holds. Rule out diurnal against the same hours a week earlier first. -Three latent hazards in the same file: `await dequeue(...)` has **no timeout**, so a hang on Redis leaves `loopRunning` true forever and every later `poll()` short-circuits, parks for `WAITER_TIMEOUT_MS` (50s) and returns null — that queue is dead for the life of the process and only a restart clears it. `tryDequeue` **recurses** once per skipped job (deferred-failure, invalid-schema, DISCARD, delay paths), so discarded zombie repeats burn dispatcher budget without producing executions (520k dequeues vs 270k executions) and a burst drives unbounded async recursion. And `concurrency: 500` on the BullMQWorker is **inert** — `autorun: false` means BullMQ's internal run loop never starts, so it reads as 500-way parallel dispatch while being 1-way. +Three latent hazards in the same file: `await dequeue(...)` has **no timeout**, so a hang on Redis leaves `loopRunning` true forever and every later `poll()` short-circuits, parks for `WAITER_TIMEOUT_MS` (50s) and returns null — that queue is dead for the life of the process and only a restart clears it. `tryDequeue` **recurses** once per skipped job (deferred-failure, invalid-schema, DISCARD, delay paths), so discarded zombie repeats burn dispatcher budget without producing executions (520k dequeues vs 270k executions) and a burst drives unbounded async recursion. And `concurrency: 500` on the BullMQWorker is **inert** — `autorun: false` means BullMQ's internal run loop never starts, so it reads as 500-way parallel dispatch while being 1-way. Same root cause bites `Worker.on('failed')`: it emits from that same off run loop, so on `workerJobs`/`platform-*`/`project-*` the local handler never fires. Terminal failures reach observers only through the explicit `job.moveToFailed(...)` sites in `job-broker.ts` (deferred failure, invalid schema, engine INTERNAL_ERROR) or through `QueueEvents` off Redis' event stream — there is no third path. `runsMetadata` and `system-job-queue` use `autorun: true` and their `Worker.on('failed')` handlers fire normally. #### A worker can wedge mid-poll-loop and still report healthy diff --git a/packages/server/api/src/app/flows/flow-run/flow-runs-queue.ts b/packages/server/api/src/app/flows/flow-run/flow-runs-queue.ts index 738f73d6ebcb..2759eddc8062 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-runs-queue.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-runs-queue.ts @@ -12,6 +12,7 @@ import { resumeService } from '../../waitpoints/resume-service' import { waitpointService } from '../../waitpoints/waitpoint-service' import { WaitpointStatus } from '../../waitpoints/waitpoint-types' import { legacyRedisMetadataKey, QueueName, redisMetadataKey, runsMetadataDeduplicationId, RunsMetadataJobData, RunsMetadataQueueConfig, runsMetadataQueueFactory, RunsMetadataUpsertData } from '../../workers/job' +import { jobFailureLogger } from '../../workers/job-queue/job-failure-logger' import { flowService } from '../flow/flow.service' import { flowRunRepo } from './flow-run-service' import { flowRunSideEffects } from './flow-run-side-effects' @@ -169,6 +170,20 @@ export const runsMetadataQueue = (log: FastifyBaseLogger) => ({ }, ) + runsMetadataWorker.on('failed', (job, err) => { + const attemptsUsed = job?.attemptsMade ?? 0 + const maxAttempts = job?.opts?.attempts ?? 1 + if (attemptsUsed >= maxAttempts) { + jobFailureLogger.logJobFailed({ + queueName: QueueName.RUNS_METADATA, + jobId: job?.id, + jobType: job?.name ?? 'update-run-metadata', + error: err, + log, + }) + } + }) + await runsMetadataWorker.waitUntilReady() }, diff --git a/packages/server/api/src/app/helper/system-jobs/system-job.ts b/packages/server/api/src/app/helper/system-jobs/system-job.ts index 239ee33ddecb..7635bc426f99 100644 --- a/packages/server/api/src/app/helper/system-jobs/system-job.ts +++ b/packages/server/api/src/app/helper/system-jobs/system-job.ts @@ -4,6 +4,7 @@ import { Job, JobsOptions, Queue, Worker } from 'bullmq' import { Dayjs } from 'dayjs' import { FastifyBaseLogger } from 'fastify' import { redisConnections } from '../../database/redis-connections' +import { jobFailureLogger } from '../../workers/job-queue/job-failure-logger' import { exceptionHandler } from '../exception-handler' import { SystemJobData, SystemJobName, SystemJobSchedule } from './common' import { systemJobHandlers } from './job-handlers' @@ -58,8 +59,15 @@ export const systemJobsSchedule = (log: FastifyBaseLogger): SystemJobSchedule => systemJobWorker.on('failed', (job, err) => { const attemptsUsed = job?.attemptsMade ?? 0 - const maxAttempts = job?.opts?.attempts ?? Infinity + const maxAttempts = job?.opts?.attempts ?? 1 if (attemptsUsed >= maxAttempts) { + jobFailureLogger.logJobFailed({ + queueName: SYSTEM_JOB_QUEUE, + jobId: job?.id, + jobType: job?.name ?? 'unknown', + error: err, + log, + }) exceptionHandler.handle(err, log) } }) diff --git a/packages/server/api/src/app/workers/job-queue/job-broker.ts b/packages/server/api/src/app/workers/job-queue/job-broker.ts index 923d67eecdaf..9c5aafbc1af4 100644 --- a/packages/server/api/src/app/workers/job-queue/job-broker.ts +++ b/packages/server/api/src/app/workers/job-queue/job-broker.ts @@ -10,6 +10,7 @@ import { jobMigrations } from '../migrations/job-data-migrations' import { rateLimiterInterceptor } from './interceptors/rate-limiter-interceptor' import { zombiePollingInterceptor } from './interceptors/zombie-polling-interceptor' import { jobAssignmentTracker } from './job-assignment-tracker' +import { jobFailureLogger } from './job-failure-logger' import { InterceptorVerdict, JobInterceptor } from './job-interceptor' import { callerWaitingForResponse } from './job-queue' import { createQueueDispatcher, QueueDispatcher } from './queue-dispatcher' @@ -92,13 +93,17 @@ async function tryDequeue(worker: BullMQWorker, queueName: string, log: FastifyB { queueName, job: { id: job.id }, jobName: job.name, deferredFailure: job.deferredFailure }, '[jobBroker#tryDequeue] Failing job with deferred failure (BullMQ stalled limit exceeded)', ) - const { error: failError } = await tryCatch(() => job.moveToFailed(new UnrecoverableError(job.deferredFailure), token, false)) + const deferredError = new UnrecoverableError(job.deferredFailure) + const { error: failError } = await tryCatch(() => job.moveToFailed(deferredError, token, false)) if (failError) { log.error( { queueName, job: { id: job.id }, error: String(failError) }, '[jobBroker#tryDequeue] Failed to fail deferred-failure job', ) } + else { + jobFailureLogger.logJobFailed({ queueName, jobId: job.id, jobType: job.name, error: deferredError, log }) + } return tryDequeue(worker, queueName, log) } @@ -120,10 +125,14 @@ async function tryDequeue(worker: BullMQWorker, queueName: string, log: FastifyB { queueName, job: { id: jobId, type: migratedData.jobType }, schemaVersion: migratedData.schemaVersion, issues: parseResult.error.issues }, '[jobBroker#tryDequeue] Failing job with invalid schema as unrecoverable', ) - const { error: failError } = await tryCatch(() => job.moveToFailed(new UnrecoverableError(reason), token, false)) + const schemaError = new UnrecoverableError(reason) + const { error: failError } = await tryCatch(() => job.moveToFailed(schemaError, token, false)) if (failError) { log.error({ queueName, job: { id: jobId }, error: String(failError) }, '[jobBroker#tryDequeue] Failed to fail invalid-schema job') } + else { + jobFailureLogger.logJobFailed({ queueName, jobId, jobType: migratedData.jobType, error: schemaError, log }) + } return tryDequeue(worker, queueName, log) } @@ -263,7 +272,19 @@ export const jobBroker = (log: FastifyBaseLogger) => ({ await job.moveToCompleted({ response: undefined }, input.token, false) return } - await job.moveToFailed(new Error(buildFailedReason(input.errorMessage ?? 'Internal error', input.logs)), input.token) + const engineErrorMessage = input.errorMessage ?? 'Internal error' + const engineError = new Error(buildFailedReason(engineErrorMessage, input.logs)) + await job.moveToFailed(engineError, input.token) + if ((job.attemptsMade ?? 0) >= (job.opts?.attempts ?? 1)) { + jobFailureLogger.logJobFailed({ + queueName: input.queueName, + jobId: input.jobId, + jobType: jobData.jobType, + error: engineError, + log, + signatureOverride: jobFailureLogger.signatureFromMessage(engineErrorMessage, 'EngineError'), + }) + } return } diff --git a/packages/server/api/src/app/workers/job-queue/job-failure-logger.ts b/packages/server/api/src/app/workers/job-queue/job-failure-logger.ts new file mode 100644 index 000000000000..72ae72264f0b --- /dev/null +++ b/packages/server/api/src/app/workers/job-queue/job-failure-logger.ts @@ -0,0 +1,55 @@ +import path from 'path' +import { FastifyBaseLogger } from 'fastify' + +const STACK_FRAME_RE = /at\s+(?:.+?\s+\()?([^\s()]+):(\d+):\d+\)?/ + +export const jobFailureLogger = { + signatureOf(error: unknown): string { + const errorName = readErrorName(error) + const frame = readTopStackFrame(error) + return frame ? `${errorName}@${frame}` : errorName + }, + signatureFromMessage(message: string, sourceLabel: string): string { + const firstLine = message.split('\n', 1)[0].trim() + const colonIdx = firstLine.indexOf(':') + const head = colonIdx > 0 ? firstLine.slice(0, colonIdx) : firstLine + const clean = head.replace(/\s+/g, ' ').slice(0, 80) + return `${sourceLabel}@${clean || 'unknown'}` + }, + logJobFailed({ queueName, jobId, jobType, error, log, signatureOverride }: LogJobFailedParams): void { + const errorSignature = signatureOverride ?? jobFailureLogger.signatureOf(error) + log.error({ + queue: { name: queueName }, + job: { id: jobId ?? '', type: jobType }, + error, + errorSignature, + }, 'job.failed') + }, +} + +function readErrorName(error: unknown): string { + if (error instanceof Error) { + const declared = error.name && error.name !== 'Error' ? error.name : undefined + return declared ?? error.constructor?.name ?? 'Error' + } + return typeof error +} + +function readTopStackFrame(error: unknown): string | null { + if (!(error instanceof Error) || !error.stack) return null + for (const line of error.stack.split('\n')) { + const match = STACK_FRAME_RE.exec(line) + if (!match) continue + return `${path.basename(match[1])}:${match[2]}` + } + return null +} + +type LogJobFailedParams = { + queueName: string + jobId: string | undefined + jobType: string + error: unknown + log: FastifyBaseLogger + signatureOverride?: string +} diff --git a/packages/server/api/test/unit/app/workers/job-queue/job-failure-logger.test.ts b/packages/server/api/test/unit/app/workers/job-queue/job-failure-logger.test.ts new file mode 100644 index 000000000000..bb1b2996d380 --- /dev/null +++ b/packages/server/api/test/unit/app/workers/job-queue/job-failure-logger.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { jobFailureLogger } from '../../../../../src/app/workers/job-queue/job-failure-logger' + +describe('jobFailureLogger.signatureOf', () => { + it('groups two failures thrown at the same site under the same signature', () => { + function throwAtFixedLine(): never { + throw new Error('same call site') + } + let sigA = '' + let sigB = '' + try { throwAtFixedLine() } + catch (e) { sigA = jobFailureLogger.signatureOf(e) } + try { throwAtFixedLine() } + catch (e) { sigB = jobFailureLogger.signatureOf(e) } + expect(sigA).toBe(sigB) + expect(sigA).toMatch(/^Error@job-failure-logger\.test\.ts:\d+$/) + }) + + it('preserves the subclass name for a custom Error', () => { + class BoomError extends Error { + constructor() { + super('boom') + this.name = 'BoomError' + } + } + try { throw new BoomError() } + catch (e) { + expect(jobFailureLogger.signatureOf(e)).toMatch(/^BoomError@/) + } + }) + + it('returns just the typeof for non-Error thrown values', () => { + expect(jobFailureLogger.signatureOf('nope')).toBe('string') + expect(jobFailureLogger.signatureOf({ a: 1 })).toBe('object') + expect(jobFailureLogger.signatureOf(undefined)).toBe('undefined') + }) +}) + +describe('jobFailureLogger.signatureFromMessage', () => { + it('groups by the leading token before the first colon', () => { + expect(jobFailureLogger.signatureFromMessage('ConnectionNotFoundError: connection abc not found', 'EngineError')) + .toBe('EngineError@ConnectionNotFoundError') + expect(jobFailureLogger.signatureFromMessage('ConnectionNotFoundError: connection xyz missing', 'EngineError')) + .toBe('EngineError@ConnectionNotFoundError') + }) + + it('uses the first line when no colon is present, truncated to 80 chars', () => { + expect(jobFailureLogger.signatureFromMessage('Sandbox process exited with code 137', 'EngineError')) + .toBe('EngineError@Sandbox process exited with code 137') + }) + + it('handles empty message with unknown fallback', () => { + expect(jobFailureLogger.signatureFromMessage('', 'EngineError')).toBe('EngineError@unknown') + }) +}) From 32413bf656cff34db397d61a714543fcc03db4f3 Mon Sep 17 00:00:00 2001 From: Othman Abu Ajamieh <52608229+othmanemad@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:58:05 +0300 Subject: [PATCH 7/7] fix(waitpoints): give a looped pause its own row, and release barriers on the predicate (#14777) --- ...in-is-an-event-driven-waitpoint-barrier.md | 31 +- ...r-leaves-a-tombstone-until-the-run-ends.md | 57 ++ ...atform-configuration-row-not-an-env-var.md | 78 +++ .../engineering/ci-pr-review-hygiene.md | 7 +- brain/knowledge/flows-execution/flow-runs.md | 14 + brain/knowledge/flows-execution/index.md | 12 + .../platform-configuration.md | 6 + bun.lock | 4 +- docs/install/architecture/waitpoints.mdx | 12 +- .../reference/environment-variables.mdx | 1 + packages/core/execution/package.json | 2 +- .../src/lib/flow-run/waitpoint/index.ts | 2 + packages/core/shared/package.json | 2 +- .../platform/platform-configuration.ts | 23 +- packages/core/utils/src/lib/form-errors.ts | 3 + ...axBarrierSignalsToPlatformConfiguration.ts | 23 + .../1847000000000-IndexWaitpointLookups.ts | 40 ++ .../src/app/database/postgres-connection.ts | 4 + .../src/app/ee/agent/agent-run-controller.ts | 2 +- .../app/flows/flow-run/flow-run-service.ts | 2 +- .../flows/flow-run/flow-run-side-effects.ts | 2 +- .../src/app/flows/flow-run/flow-runs-queue.ts | 15 +- .../api/src/app/helper/system-validator.ts | 11 +- .../api/src/app/helper/system/system-props.ts | 1 + .../api/src/app/helper/system/system.ts | 1 + .../platform-configuration.controller.ts | 1 + .../platform/platform-configuration.entity.ts | 5 + .../platform-configuration.service.ts | 20 +- .../app/waitpoints/barrier-queue-factory.ts | 74 +++ .../api/src/app/waitpoints/barrier-queue.ts | 85 +++ .../api/src/app/waitpoints/barrier-service.ts | 342 +++++++++++ .../src/app/waitpoints/resume-controller.ts | 8 +- .../app/waitpoints/resume-delay-handler.ts | 2 +- .../api/src/app/waitpoints/resume-service.ts | 147 ++++- .../src/app/waitpoints/waitpoint-entity.ts | 4 + .../src/app/waitpoints/waitpoint-service.ts | 137 ++++- .../app/waitpoints/waitpoint-signal-entity.ts | 6 +- .../api/src/app/waitpoints/waitpoint-types.ts | 2 + .../server/api/src/app/workers/job/index.ts | 1 + .../workers/migrations/refill-paused-jobs.ts | 21 +- .../api/src/app/workers/worker-module.ts | 4 + .../ce/flows/flow-run/barrier.test.ts | 551 ++++++++++++++++++ .../flows/flow-run/execute-flow-e2e.test.ts | 255 +++++++- .../ce/flows/flow-run/resume-service.test.ts | 90 ++- .../ce/flows/flow-run/waitpoint.test.ts | 144 ++++- .../platform/platform-configuration.test.ts | 46 +- .../ce/workers/refill-paused-jobs.test.ts | 171 ++++++ .../handler/context/step-execution-path.ts | 4 + .../engine/src/lib/handler/piece-executor.ts | 10 +- .../context/step-execution-path.test.ts | 22 + .../web/public/locales/en/translation.json | 7 + .../platform/infra/configurations/index.tsx | 17 +- .../infra/configurations/limits-section.tsx | 77 +++ .../configurations/telemetry-section.tsx | 5 +- .../infra/configurations/index.test.ts | 54 ++ 55 files changed, 2537 insertions(+), 130 deletions(-) create mode 100644 brain/knowledge/decisions/000033-a-released-barrier-leaves-a-tombstone-until-the-run-ends.md create mode 100644 brain/knowledge/decisions/000035-the-barrier-signal-cap-is-a-platform-configuration-row-not-an-env-var.md create mode 100644 packages/server/api/src/app/database/migration/postgres/1845000000000-AddMaxBarrierSignalsToPlatformConfiguration.ts create mode 100644 packages/server/api/src/app/database/migration/postgres/1847000000000-IndexWaitpointLookups.ts create mode 100644 packages/server/api/src/app/waitpoints/barrier-queue-factory.ts create mode 100644 packages/server/api/src/app/waitpoints/barrier-queue.ts create mode 100644 packages/server/api/src/app/waitpoints/barrier-service.ts create mode 100644 packages/server/api/test/integration/ce/flows/flow-run/barrier.test.ts create mode 100644 packages/server/api/test/integration/ce/workers/refill-paused-jobs.test.ts create mode 100644 packages/server/engine/test/handler/context/step-execution-path.test.ts create mode 100644 packages/web/src/app/routes/platform/infra/configurations/limits-section.tsx create mode 100644 packages/web/test/app/routes/platform/infra/configurations/index.test.ts diff --git a/brain/knowledge/decisions/000015-fan-in-is-an-event-driven-waitpoint-barrier.md b/brain/knowledge/decisions/000015-fan-in-is-an-event-driven-waitpoint-barrier.md index c8c9c5e21113..94095a8c881a 100644 --- a/brain/knowledge/decisions/000015-fan-in-is-an-event-driven-waitpoint-barrier.md +++ b/brain/knowledge/decisions/000015-fan-in-is-an-event-driven-waitpoint-barrier.md @@ -14,7 +14,7 @@ producer's ordinal), nullable `label` (the human name in the summary) and a smal Release is an **unconditional floor rule**: a sealed barrier releases once no signal is still `PENDING`. No configuration can produce a hang; `policy` only ever releases *sooner* — `requiredSuccesses` (K-of-N approvals) and `releaseOnFirstFailure` (veto). Evaluation is a coalesced `BarrierJobName.EVALUATE` job on a -dedicated `BARRIER_JOBS` queue, deduplicated on the barrier id, owned by the waitpoints module. Counts are +dedicated `BARRIER_EVALUATION` queue, deduplicated on the barrier id, owned by the waitpoints module. Counts are taken once, from a single `GROUP BY status`, and handed to a pure predicate. Signals are deleted on release, in the same transaction that completes the waitpoint — summary first, then complete, then delete. @@ -51,7 +51,7 @@ all, purely because child rows appear later than the dispatch that created them. already returns the ceiling, and a shorter deadline is a `policy` input, so both are known at create. - **Evaluation must be coalesced, and coalescing must not swallow its own last signal.** At 10 000 signals, evaluating per signal is ~100M row reads. BullMQ holds a deduplication key while the job is queued *and* - active, so the handler's **first statement** is `clearEvaluationDeduplication`, and every producer commits + active, so the handler's **first statement** is `clearEvaluationDedupKey`, and every producer commits its signal row **before** enqueuing. Otherwise the final signal lands while the job meant to see it is already running, its enqueue is dropped as a duplicate, and the barrier waits out its deadline holding a run that was ready to resume. @@ -61,6 +61,31 @@ all, purely because child rows appear later than the dispatch that created them. `shouldReleaseBarrier({ policy, sealed, counts })` takes counts rather than issuing its own queries, so one `GROUP BY status` replaces up to three round-trips and the policy matrix is unit-testable without a database. +- **Identity is path-keyed, and that needed no new column.** The engine sends `loop_1:3/loop_2:0/approval` + in the existing `stepName` field, which is only ever an identity key inside `waitpoint-service`. Which + fixes a live bug on the way past: a delay or approval **inside a loop** used to reuse one waitpoint row + across every iteration, so iteration 2 found iteration 1's COMPLETED row in `createForPause`'s + pre-completed check and skipped its pause entirely. +- **Two per-run waitpoint reads stopped being sound and were fixed here.** `getByFlowRunId` became + `findCompletedWaitpointIfRunIsIdle` — **null when any PENDING row exists for the run**, else the newest COMPLETED + one — because per-iteration rows mean a run can hold a COMPLETED leftover *and* an open PENDING pause at + once, and both callers delete the row they find and enqueue a resume. `findNonFanInByFlowRunId` became + `findSubflowWaitpoint` and prefers PENDING, newest first. `findPendingByVersion` is **still** unsound + for parallel branches; nothing here creates that case. +- **The resume guards moved to the entry point rather than becoming a flag.** `resumeFromWaitpoint` + **refuses barriers, always** — by addressed waitpoint type, or, on the by-run legacy routes, by "does this + run hold any PENDING barrier". Every unscoped route goes through it, so there is no `false` to forget and + no parameter to default wrong. What the guard is *not* is structural: `resumeTrusted` and + `resumeTrustedWithoutLock` are ordinary public members of `resumeService`, one `if` away from the guard + they skip. `flow-runs-queue`'s pre-completed recovery calls the unlocked variant from outside the module, + so a module-private `resumeTrusted` would not have worked as written. Treated as acceptable because the + reachable surface — the HTTP routes — is closed; if a third caller appears, make the boundary real rather + than adding a second convention. +- **`release` is what happens to a barrier; `resume` is what happens to a run.** The two verbs sit one layer + apart — `barrierService.release` closes the barrier, then asks `resumeService` to resume the run — so a + `resumeService.releaseBarrier` named the wrong layer and hid that the same method also serves plain `DELAY` + and `WEBHOOK` waitpoints, which is exactly what `flow-runs-queue` calls it for. It is `resumeTrusted`: + resume, skipping the barrier guard, because the caller is internal rather than an HTTP route. - **An external actor never addresses the waitpoint.** Their link carries a **signal id** on `/v1/flow-runs/:id/signals/:signalId/confirm` — in the path, not the query string, because `resumePayload` is built from `{ body, headers, queryParams }` and persisted with the run. The link must never carry @@ -76,7 +101,7 @@ all, purely because child rows appear later than the dispatch that created them. - **The machinery ships able to carry K-of-N approvals; no approval piece is rewired yet.** Per-signal links and `reasonRequiredOn` exist now because retrofitting them means another migration. - **`policy` carries no deadline override.** The plan allowed "or the policy's shorter value"; nothing - produces one, so `resolveDeadline()` is `now + AP_PAUSED_FLOW_TIMEOUT_DAYS` and takes no arguments. Add the + produces one, so `defaultBarrierDeadline()` is `now + AP_PAUSED_FLOW_TIMEOUT_DAYS` and takes no arguments. Add the field when a caller for it exists, not before. - **The 2 000-character reason bound stays a handler check, not a request schema.** The confirm route is `app.all` serving an HTML form: a Zod `body` schema would also apply to the GET that renders the page, and diff --git a/brain/knowledge/decisions/000033-a-released-barrier-leaves-a-tombstone-until-the-run-ends.md b/brain/knowledge/decisions/000033-a-released-barrier-leaves-a-tombstone-until-the-run-ends.md new file mode 100644 index 000000000000..a6d563b19edb --- /dev/null +++ b/brain/knowledge/decisions/000033-a-released-barrier-leaves-a-tombstone-until-the-run-ends.md @@ -0,0 +1,57 @@ +--- +icon: 🪦 +status: accepted +--- + +# A released barrier leaves a tombstone until the run ends + +## Decision + +Delivering a resume no longer means deleting the waitpoint row. `waitpointService.consume` owns the +rule: a `BARRIER` is tombstoned at a third status, `CONSUMED`, and every other type is still deleted. +The tombstone lives until the run reaches a terminal status, where the existing `deleteByFlowRunId` +sweep clears it. The legacy by-run resume guard (`hasBarrier`) therefore asks whether the run holds a +barrier row **at all**, in any status, instead of whether one is `PENDING`. + +## Context + +A barrier's release boundary is predicate-owned: only its own predicate or its deadline may release +it. The guard enforcing that on the deprecated V0 `/:id/requests/:requestId` routes asked +`hasPendingBarrier`, which filters `status: PENDING`, so it stopped guarding the moment the barrier +was released. Two windows followed — between `closeBarrier` committing `COMPLETED` and the trusted +resume consuming it, and the much wider one after delivery, since a run stays `PAUSED` until the +engine's next progress upload. In both, a legacy resume was accepted and enqueued a second job with +a request-controlled payload, under a job id BullMQ would not collapse against the trusted one. + +The root cause was that "this resume was delivered" was encoded as *row-absence*, which makes a +delivered barrier and a pre-waitpoint legacy pause indistinguishable — both are "no rows" — while +the legacy path may only serve the second. + +## Why + +Making delivery explicit is what lets the two be told apart, and `waitpoint.status` is +`character varying` with a TypeScript-only enum, so a third value costs no migration. + +Rejected: a `flow_run.pausedWaitpointId` column — a real migration, and `pauseMetadata` was not +available to reuse (it is deprecated and pending removal). A Redis marker with a TTL — puts a +security boundary on an expiry and a flush reopens the hole. Deleting the legacy path outright — +tempting at 4.5 months past deprecation with a 30-day default pause timeout, but a self-hoster +jumping many versions would silently lose every in-flight approval. + +Tombstoning is scoped to barriers rather than all waitpoints because the barrier is the only +waitpoint whose release boundary is predicate-owned; for `DELAY` / `WEBHOOK` the unguessable link +*is* the authorization. Widening it would leak `CONSUMED` rows into `findSubflowWaitpoint`'s +any-status fallback and into `createForPause`'s unique `(flowRunId, stepName)` slot. + +## Consequences + +A run that has ever held a barrier can never use the legacy by-run resume path again. That is +correct, not a side effect: that path serves only runs which never had a waitpoint row at all. + +`hasPendingBarrier` survives alongside `hasBarrier` and `findUndeliveredCompletedWaitpoint` must keep +calling the *pending* one — switching it to existence would return null whenever a `CONSUMED` barrier +exists and hang a later non-barrier delivery on the same run. + +The invariant that a row still sitting at `COMPLETED` was never delivered is preserved, now carried +by an explicit status rather than by absence. Cost is one small row per barrier for the life of the +run; `barrierService.create` already deletes any non-`PENDING` row occupying its step's slot. diff --git a/brain/knowledge/decisions/000035-the-barrier-signal-cap-is-a-platform-configuration-row-not-an-env-var.md b/brain/knowledge/decisions/000035-the-barrier-signal-cap-is-a-platform-configuration-row-not-an-env-var.md new file mode 100644 index 000000000000..b611aba38347 --- /dev/null +++ b/brain/knowledge/decisions/000035-the-barrier-signal-cap-is-a-platform-configuration-row-not-an-env-var.md @@ -0,0 +1,78 @@ +--- +status: accepted +--- + +# The barrier signal cap is a platform configuration row, not an env var + +## Decision + +`AP_MAX_BARRIER_SIGNALS` becomes `maxBarrierSignals`, an `integer NOT NULL DEFAULT 10000` column on +`platform_configuration`, with a `Limits` section on the Configurations page to edit it. The row is +authoritative and born from the env var, exactly as [000033](000033-platform-configuration-rows-are-authoritative-and-created-on-first-read.md) +does it. The env var survives only as the birth default for a platform that has no row yet. + +Cloud is short-circuited **in the service**: `maxBarrierSignals({ platformId })` returns the system prop +and ignores the row when `edition === CLOUD`, the same shape `isProductTelemetryEnabled` already uses. +`platformId` is added to `CreateBarrierParams` so the caller supplies it rather than +`barrierService.create` resolving it from `projectId`. There is no unlimited value and no nullable +column. + +## Context + +The variable is new on `feat/barrier-service` and has never shipped: it is absent from `main`, from the +published env var table, and from every deploy surface. So this is a choice about where a dial lives +before release, with no upgrade path to protect, no breaking-change label and no docs migration. + +Its only read site is `assertSignalCountWithinLimit` in `waitpoints/barrier-service.ts`, and +`barrierService.create` has no production caller yet, so the signature is free to change. + +## Why + +000033 draws a line that this decision deliberately walks up to: configurations hold behaviour, +`platform_plan` holds entitlements, and a **cap** is the `MAX_RECORDS_PER_TABLE` shape that decision +keeps off the page, because `POST /v1/platform-configurations` carries no edition guard and a limit +living there is a self-serve quota bypass the day the page stops being Cloud-hidden. + +The cap goes there anyway because the bypass is closed where 000033 says invariants must be closed: in +the server, on the value. Hiding the page was never the enforcement, and the Cloud short-circuit in the +service makes the row unreadable as a cap on Cloud regardless of what an admin writes. + +**Rejected: `platform_plan`.** It is the correct home for a limit that is sold, and it is CE-readable +via `platformService.getOneWithPlanOrThrow`. It was rejected because nobody sells barrier width, and a +plan column would put a self-hoster's own protective cap behind an entitlement they cannot edit. + +**Rejected: env var as a `min(row, env)` ceiling.** It preserves an operator guarantee on a +multi-platform EE install, where a platform admin is not the operator and could raise a cap that +consumes shared Postgres and Redis. It was rejected as one concept too many for a case that does not +yet exist, and because it reintroduces the two-sources-of-truth shape 000033 rejected for telemetry. +This is the variant to revisit if multi-platform installs start mattering here. + +## Consequences + +- The error message must stop saying "raise `AP_MAX_BARRIER_SIGNALS`". It is read by a project member + who may not be a platform admin, so it names the limit and the page without implying the reader can + change it. +- `platformId` on `CreateBarrierParams` means every future caller has to hold platform scope. The + waitpoints module already resolves it the other way in `resume-service.ts` + (`projectService.getPlatformId`), so the two paths in one module now differ on purpose: the check runs + before the transaction that inserts up to 10 000 signal rows, and the caller was cheaper to widen + while it has no production implementation. +- **The number reaches the browser whether or not the builder uses it.** The GET response is the whole + `PlatformConfiguration` object, and `TelemetryProvider` wraps the router and already caches that row + for every signed-in user, so any builder component can read it with an existing hook and no new + endpoint. Nothing is built on that now: no fan-out step exists, and the real fan-out shape dispatches + over a runtime array, so a design-time count does not exist for the general case. +- Because Cloud ignores the row, the raw column is **not** the effective cap. Whoever adds a builder + surface must serve a computed effective value rather than recompute the edition rule in the browser, + which is how 000033's split-brain bug happened. +- **No `tryCatch` around the read, because every failure that could reach it is already fatal to + `barrierService.create`.** Postgres down kills the transaction and the signal inserts, Redis down kills + `enqueueEvaluation`, and the `distributedLock` is taken only on the create path, once per platform for + the life of the install. Fail-closed is also the do-nothing option, so there is no code to write. +- **The `Limits` section is written but not rendered.** `limits-section.tsx` exists and is wired to the + same form schema, but `configurations/index.tsx` does not mount it, so no edition shows the field yet. + The row, the API and the cap are all live regardless: the value every platform runs on is the one + `AP_MAX_BARRIER_SIGNALS` seeded, and an existing platform's row can only be changed through + `POST /v1/platform-configurations` until the section is mounted. Rendering it is one import and one + line. +- Postgres only. There is no sqlite migration for `platform_configuration`. diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 93ae61d793bc..f8f33538c536 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -44,7 +44,7 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Linting a single server test file OOMs node at its default heap — pass `NODE_OPTIONS=--max-old-space-size=8192`.** `npx eslint packages/server/api/test/.../.test.ts` on one file died with `FATAL ERROR: Reached heap limit` after ~23s at 2GB, because the type-aware config loads the whole `packages/server/api` program regardless of how few files you name. It reads as a broken lint setup, not as a memory ceiling. The same run with an 8GB heap finishes and reports normally. - **`bun install` on a recent bun adds `"configVersion": 0` to `bun.lock`, which is not on `main`.** It rides along in any commit that touches the lockfile and reads as an unrelated change; drop the line and re-run `bun install --frozen-lockfile` to confirm the lockfile is still consistent without it. **You do not have to run `bun install` to get it** — a bare `npx vitest run ` in `packages/web` was enough to rewrite the lockfile with `configVersion` *plus* every workspace version already bumped in the branch, so `git status` after any verification run is worth a glance before committing. -- **A version bump that merges cleanly can still be wrong — check what `main`'s number *means*, not whether it conflicts.** Two branches bumping the same package to the same number do not conflict, so git takes it silently; but if `main`'s copy of `0.5.0` is another PR's content and yours adds further exports on top, you ship new exports under an already-published version and nothing catches it. Seen merging [#15001](https://github.com/activepieces/activepieces/pull/15001) after the six-providers PR landed: `core-piece-types` and `pieces-framework` auto-merged at `0.5.0` / `0.37.0` and both needed a further bump. Only a *conflicting* version (like `core/shared` `0.140.0` vs `0.141.0`) forces you to think; the clean ones are the dangerous ones. After any merge, re-check every package you bumped against `git show origin/main:/package.json`. The reverse also happens: when review makes you *delete* code, the bump it justified can become dead — after acting on review, `git diff origin/main...HEAD -- /src` and drop the bump if it is empty. On #15001 two packages ended up byte-identical to `main` while still carrying a bump, which is noise at best and a version collision at worst. +- **A version bump that merges cleanly can still be wrong — check what `main`'s number *means*, not whether it conflicts.** Two branches bumping the same package to the same number do not conflict, so git takes it silently; but if `main`'s copy of `0.5.0` is another PR's content and yours adds further exports on top, you ship new exports under an already-published version and nothing catches it. Seen merging [#15001](https://github.com/activepieces/activepieces/pull/15001) after the six-providers PR landed: `core-piece-types` and `pieces-framework` auto-merged at `0.5.0` / `0.37.0` and both needed a further bump. Only a *conflicting* version (like `core/shared` `0.140.0` vs `0.141.0`) forces you to think; the clean ones are the dangerous ones. After any merge, re-check every package you bumped against `git show origin/main:/package.json`. The reverse also happens: when review makes you *delete* code, the bump it justified can become dead — after acting on review, `git diff origin/main...HEAD -- /src` and drop the bump if it is empty. On #15001 two packages ended up byte-identical to `main` while still carrying a bump, which is noise at best and a version collision at worst. **Nothing in CI catches a *missing* bump outside `packages/pieces/`.** `validate-publishable-packages` is the only bump check and it exempts every non-piece package, so a `core/shared` change with no bump goes green on every check. Seen on [#15078](https://github.com/activepieces/activepieces/pull/15078): nine new exports added to `core/shared/src/lib/automation/mcp/mcp-oauth.ts` with `package.json` still at `0.152.0` — the same number `main` had just reached via a *different* PR (#15162), so the collision was invisible and all 17 checks passed. Reviewing a PR that touches `core/shared`, diff the version by hand. `bun.lock` mirrors workspace versions and drifts behind — it read `0.151.0` while `main` was on `0.152.0`, because bumps routinely land without it (#15162 changed `package.json` alone). Sync it with **`bun install --lockfile-only`**, which needs no `node_modules` and so works in a throwaway worktree; on #15078 that produced exactly one line and swept up no unrelated drift. The 103-line sweep noted above is a *pieces* phenomenon — don't assume it for `core/*`, just read the diff before staging. - **`@activepieces/shared` re-exports from `@activepieces/core-execution`, so a partial rebuild produces phantom "has no exported member" errors in unrelated files.** Rebuilding `core/shared` against a stale `core/execution` dist drops those re-exports, and the API typecheck then fails in `ee/agent/*` on symbols like `GetPersonalizationConfigRequest` — which live in `core/execution/src/lib/workers/worker-contract.ts`, not in shared at all. It reads exactly like a bad merge. The dependency order that actually works is `core/utils` → `core/piece-types` → `core/formula` → `core/execution` → `core/shared` → `server/utils` → `pieces/framework` → `core/ai-providers`; skipping a link silently poisons everything downstream of it. The same staleness makes an editor report missing enum members that exist in the source. **There is no api typecheck "error baseline" — rebuild first, then trust it.** A stale `core/shared/dist` reliably yields a handful of `ee/agent` errors that look permanent enough to be waved off as known, which is how a real error hides among them; after `npx turbo run build --filter=@activepieces/shared`, `tsc --noEmit -p packages/server/api/tsconfig.app.json` comes back completely clean. The same staleness invents errors in files a rebase just touched, so during a conflict resolution rebuild before concluding you resolved it wrong. - **To pull a file back out of a PR, restore it from the merge-base, never from `origin/main`.** A PR's diff is computed against the merge-base, so `git checkout origin/main -- ` does not "revert" the file — it imports every change `main` made to it since the fork and attributes them to you. Dropping one web file from [#15001](https://github.com/activepieces/activepieces/pull/15001) that way would have silently added 82 insertions / 44 deletions of somebody else's work. `git checkout $(git merge-base origin/main HEAD) -- ` makes it byte-identical to where the branch started, so it leaves the diff entirely and merges cleanly instead of conflicting. Verify with `git diff --quiet $(git merge-base origin/main HEAD) -- ` before committing, and read `git status` first — a `bun.lock` left dirty by an earlier `bun install` loves to ride along on a commit like this. - **Retargeting a stacked PR to `main` does not drop its base branch — it merges the whole thing.** A PR opened against a long-lived feature branch shows a small diff *relative to that base*, but `gh pr edit --base main` only moves the target; the branch still contains every commit of its old base. [#14593](https://github.com/activepieces/activepieces/pull/14593) read as 2 docs files against `feat/autumn-billing-integration` and as 198 commits / 211 files / +12k lines against `main`. Check with `git diff --stat origin/main...` **before** retargeting, and if it disagrees with the PR page, cherry-pick that PR's own commits onto `main` and force-push instead. A "conflict" on such a PR is often against the feature base only — those same commits can apply to `main` cleanly. @@ -67,5 +67,10 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Nothing rolls `## Unreleased` over at release time, and the docs site is unversioned — so a breaking-changes entry has to name its own version.** No workflow or script writes to `docs/install/reference/breaking-changes.mdx` (`breaking-change-check.ts` only reads it), and `git log -S"## 0.88"` on the file comes back empty: the heading has not moved since 0.87.0, so entries for work that shipped months ago still sit under "Unreleased" (PM2 removal in 0.88.2, cache pre-warm gate and workspace naming in 0.89.0, …). `docs/docs.json` has no versioning either, so there is one live page for every self-hoster whatever version they run, published on merge rather than on release — the version heading is the *only* thing telling a reader whether a change is already in their build. So before adding an entry, run `git tag --contains ` on the change it describes and file it under the release that actually shipped it; only genuinely unshipped work belongs under "Unreleased". What points self-hosters at the page in the first place is `release-drafter.yml`, which appends a "review the Breaking Changes page" line to every release body and groups `⛓️‍💥 breaking-change` PRs under their own heading — which also means a docs-only PR back-documenting an old change shows up in the *next* release's breaking-change list. - **Greptile enforces the file-order rule on *private* constants too, which CLAUDE.md only states for exported ones.** CLAUDE.md says "Exported types and constants must be placed at the end of the file" and gives the order as imports → exports → helpers → types; Greptile reads that as covering module-private constants as well, and flags a `const` sitting above the file's exported symbol (P2 on [#15226](https://github.com/activepieces/activepieces/pull/15226), for two constants only read inside the service they sat above). It has that as a stored custom-context memory, so it will keep raising it. Put private constants in the helpers section below the export — hoisting is a non-issue when they are only read at call time. - **`turbo run lint --filter=` never typechecks that package, so "0 errors" is no proof it compiles — run `turbo run build --filter=` as well.** A package's `lint` script is `eslint 'src/**/*.ts'` with no `tsc`, and the turbo `lint` task's `dependsOn: ["^build"]` builds the package's *upstream dependencies* only, never itself — the `^` is the whole story. So a type error in the package you are editing is invisible to a filtered lint run. CI still catches it, but confusingly under the **lint** job as well as **main**, because linting the whole repo makes your package an upstream of something else whose `^build` finally compiles it. A branch can therefore sit red on two jobs for one `tsc` error that a local filtered lint reported clean. +- **Never set `maxWorkers` in `packages/server/api/vitest.config.ts` — the api suites are not alone on the runner.** `ci.yml` runs `turbo run test-ce test-ee test-cloud check-migrations --filter=api` as concurrent turbo tasks, in parallel with a second `turbo run test` line and `web#build`'s vite run, all on one 4-vCPU / 16 GB `ubuntu-latest` box. Each vitest fork boots a Fastify server *and* a PGlite database, so the config's per-project worker count is really that number **times three editions**. A stray `maxWorkers: 4` on [#14777](https://github.com/activepieces/activepieces/pull/14777) raised the forks pool from its default `availableParallelism() - 1` (3) to 4 — 12 forks instead of 9 — and took the job down twice in a row with failures that never named the cause: first `execute-flow-e2e`'s `beforeAll` timing out while **1028 tests passed**, then the whole runner SIGKILLed mid-build (`web#build exited (137)`, `check-migrations` SIGTERM, api suites never finishing). Both read as "a test broke". Neither was. The tell is `The runner has received a shutdown signal` immediately above the 137, and the fix is to delete the line, not to raise a timeout. Same reason the config's `hookTimeout` is 120s: a loaded runner pushes a server boot past a minute, so a file that pins its own smaller `beforeAll` timeout (`}, 30_000)`) silently opts out of the protection and becomes the first thing to fail. Check the blast radius before calling it fixed: a config mistake made on a long-lived stack branch replicates by merge into every descendant, so deleting it from the PR in front of you leaves the rest of the stack to reintroduce it on the next rebase. This one originated once on `feat/subflow-fan-in-barrier` (2026-08-12) and reached **14 branches, 13 of them open PRs** across three unrelated stacks — `git log -S maxWorkers` over `origin/main` proved it never landed there, and a loop of `git show : | grep` found every branch still carrying it. +- **A red `api#test-ce` names one failing test, not all of them — the script runs `--bail 1`.** `test-ce-command` is `vitest run test/integration/ce --bail 1`, so CI stops at the first failure and the report is a floor, not a count. On `feat/barrier-edges` it showed one failing assertion; the file actually had three, all the same root cause. Before concluding a branch has one problem, re-run the suspect file locally *without* `--bail` — `npx vitest run ` — and fix the set. +- **Fixing an infra failure can turn a branch from red to a different red, and that is progress, not a regression.** A runner killed mid-run (`shutdown signal` above `exited (137)`) reports no test results at all, so genuine failures underneath it are invisible. When the OOM is fixed the suites finally run to completion and those failures surface for the first time — on this stack, thirteen branches went from an OOM red to a real `barrier.test.ts` red the moment the runner survived. Check whether the *previous* run reached a test summary at all before blaming the change that unmasked it. +- **A `beforeAll` that overrides the vitest timeout *downwards* is a slow-motion flake, and it fails in a way that blames your diff.** [`execute-flow-e2e.test.ts`](../../../packages/server/api/test/integration/ce/flows/flow-run/execute-flow-e2e.test.ts) passes `30_000` to `beforeAll` even though `packages/server/api/vitest.config.ts` sets `hookTimeout: 60000`, and spends 5s of that budget on a hardcoded `setTimeout`. Inside the remaining ~25s it boots a fresh app, runs the **whole migration chain**, starts queue consumers and a worker. That chain has grown 293 → 376 postgres migrations since the hook was written (`d2c42816013`, "feat: worker v2", #11608, 2026-03-15 — authored *after* the 60s default was already in place), so on a loaded runner it now times out. When it does, all 9 of its tests report **skipped**, not failed: the summary reads `1 failed | 84 passed` with no failing assertion anywhere, and `--bail 1` kills the rest of the CE task. Seen on [#15078](https://github.com/activepieces/activepieces/pull/15078), an MCP-OAuth-only PR that cannot reach that code. No single commit broke it, so there is nobody to blame and nothing to revert — the fix is to delete the override and poll for worker readiness instead of sleeping blind. When triaging a red CE task, check whether the failure is an assertion or a hook timeout **before** blaming the diff. +- **Git hooks cannot run from a `git worktree`: `.husky/_/husky.sh` is an install artifact that only exists in the tree where `bun install` ran.** Every commit and push from a worktree dies on `.: cannot open .husky/_/husky.sh`, before the hook's own logic is reached. Don't reach for `--no-verify` — push the worktree's commit from the main checkout (`git push origin :`; worktrees share the object store), so `pre-push` actually bootstraps and its "no direct pushes to `main`" guard runs. `SKIP_CHECK=1` is the repo's own sanctioned way to skip the lint/test prompt. Note `pre-push` special-cases `CLAUDE_PUSH=yes` into running the **full** lint + unit + API suite, so an agent that silently bypasses the hook is skipping a check the repo added deliberately for it — say so out loud when you do. - **Docs videos belong on `cdn.activepieces.com/videos/docs/`, never in the repo — and `docs/resources/` is the legacy path that suggests otherwise.** Every `