@agnocon/piece-canny 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE.MIT-AP +24 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +42 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/actions/create-post.d.ts +14 -0
  7. package/dist/lib/actions/create-post.d.ts.map +1 -0
  8. package/dist/lib/actions/create-post.js +96 -0
  9. package/dist/lib/actions/create-post.js.map +1 -0
  10. package/dist/lib/actions/create-vote.d.ts +5 -0
  11. package/dist/lib/actions/create-vote.d.ts.map +1 -0
  12. package/dist/lib/actions/create-vote.js +45 -0
  13. package/dist/lib/actions/create-vote.js.map +1 -0
  14. package/dist/lib/actions/delete-vote.d.ts +5 -0
  15. package/dist/lib/actions/delete-vote.d.ts.map +1 -0
  16. package/dist/lib/actions/delete-vote.js +45 -0
  17. package/dist/lib/actions/delete-vote.js.map +1 -0
  18. package/dist/lib/actions/list-posts.d.ts +9 -0
  19. package/dist/lib/actions/list-posts.d.ts.map +1 -0
  20. package/dist/lib/actions/list-posts.js +102 -0
  21. package/dist/lib/actions/list-posts.js.map +1 -0
  22. package/dist/lib/actions/retrieve-post.d.ts +4 -0
  23. package/dist/lib/actions/retrieve-post.d.ts.map +1 -0
  24. package/dist/lib/actions/retrieve-post.js +37 -0
  25. package/dist/lib/actions/retrieve-post.js.map +1 -0
  26. package/dist/lib/auth.d.ts +2 -0
  27. package/dist/lib/auth.d.ts.map +1 -0
  28. package/dist/lib/auth.js +35 -0
  29. package/dist/lib/auth.js.map +1 -0
  30. package/dist/lib/common/client.d.ts +15 -0
  31. package/dist/lib/common/client.d.ts.map +1 -0
  32. package/dist/lib/common/client.js +34 -0
  33. package/dist/lib/common/client.js.map +1 -0
  34. package/dist/lib/common/props.d.ts +2 -0
  35. package/dist/lib/common/props.d.ts.map +1 -0
  36. package/dist/lib/common/props.js +38 -0
  37. package/dist/lib/common/props.js.map +1 -0
  38. package/dist/lib/triggers/new-comment.d.ts +2 -0
  39. package/dist/lib/triggers/new-comment.d.ts.map +1 -0
  40. package/dist/lib/triggers/new-comment.js +52 -0
  41. package/dist/lib/triggers/new-comment.js.map +1 -0
  42. package/dist/lib/triggers/new-post.d.ts +2 -0
  43. package/dist/lib/triggers/new-post.d.ts.map +1 -0
  44. package/dist/lib/triggers/new-post.js +56 -0
  45. package/dist/lib/triggers/new-post.js.map +1 -0
  46. package/dist/lib/triggers/new-vote.d.ts +2 -0
  47. package/dist/lib/triggers/new-vote.d.ts.map +1 -0
  48. package/dist/lib/triggers/new-vote.js +64 -0
  49. package/dist/lib/triggers/new-vote.js.map +1 -0
  50. package/dist/lib/triggers/post-status-changed.d.ts +2 -0
  51. package/dist/lib/triggers/post-status-changed.d.ts.map +1 -0
  52. package/dist/lib/triggers/post-status-changed.js +56 -0
  53. package/dist/lib/triggers/post-status-changed.js.map +1 -0
  54. package/dist/lib/triggers/register-trigger.d.ts +13 -0
  55. package/dist/lib/triggers/register-trigger.d.ts.map +1 -0
  56. package/dist/lib/triggers/register-trigger.js +42 -0
  57. package/dist/lib/triggers/register-trigger.js.map +1 -0
  58. package/package.json +46 -0
  59. package/src/index.ts +39 -0
  60. package/src/lib/actions/create-post.ts +90 -0
  61. package/src/lib/actions/create-vote.ts +39 -0
  62. package/src/lib/actions/delete-vote.ts +39 -0
  63. package/src/lib/actions/list-posts.ts +99 -0
  64. package/src/lib/actions/retrieve-post.ts +31 -0
  65. package/src/lib/auth.ts +34 -0
  66. package/src/lib/common/client.ts +44 -0
  67. package/src/lib/common/props.ts +36 -0
  68. package/src/lib/triggers/new-comment.ts +48 -0
  69. package/src/lib/triggers/new-post.ts +52 -0
  70. package/src/lib/triggers/new-vote.ts +60 -0
  71. package/src/lib/triggers/post-status-changed.ts +52 -0
  72. package/src/lib/triggers/register-trigger.ts +50 -0
@@ -0,0 +1,39 @@
1
+ import { createAction, Property } from '@agnocon/pieces-framework';
2
+
3
+ import { cannyAuth } from '../auth';
4
+ import { cannyRequest } from '../common/client';
5
+
6
+ export const deleteVoteAction = createAction({
7
+ auth: cannyAuth,
8
+ name: 'delete_vote',
9
+ displayName: 'Delete Vote',
10
+ description: 'Removes a vote from a post for a given user.',
11
+ audience: 'both',
12
+ aiMetadata: {
13
+ description:
14
+ "Removes a specific user's vote from a Canny post. Use to retract support previously registered for a post. Requires the post ID and the voter Canny user ID. Idempotent: removing an already-absent vote leaves the post in the same state.",
15
+ idempotent: true,
16
+ },
17
+ props: {
18
+ postID: Property.ShortText({
19
+ displayName: 'Post ID',
20
+ description: 'The unique identifier of the post to remove the vote from.',
21
+ required: true,
22
+ }),
23
+ voterID: Property.ShortText({
24
+ displayName: 'Voter ID',
25
+ description: 'The unique identifier of the voter (Canny user ID).',
26
+ required: true,
27
+ }),
28
+ },
29
+ async run({ auth, propsValue }) {
30
+ return await cannyRequest({
31
+ apiKey: auth.secret_text,
32
+ path: '/votes/delete',
33
+ body: {
34
+ postID: propsValue.postID,
35
+ voterID: propsValue.voterID,
36
+ },
37
+ });
38
+ },
39
+ });
@@ -0,0 +1,99 @@
1
+ import { createAction, Property } from '@agnocon/pieces-framework';
2
+
3
+ import { cannyAuth } from '../auth';
4
+ import { cannyRequest, cleanBody } from '../common/client';
5
+
6
+ export const listPostsAction = createAction({
7
+ auth: cannyAuth,
8
+ name: 'list_posts',
9
+ displayName: 'List Posts',
10
+ description: 'Returns a list of posts for a board, with optional filtering and sorting.',
11
+ audience: 'both',
12
+ aiMetadata: {
13
+ description:
14
+ 'Lists Canny posts, optionally scoped to one board or queried across all boards when no board is given, with optional search text, status filter, sort order, and pagination (limit/skip). Use to discover or search posts rather than fetch one known post by ID. Read-only and idempotent.',
15
+ idempotent: true,
16
+ },
17
+ props: {
18
+ boardID: Property.Dropdown({
19
+ auth: cannyAuth,
20
+ displayName: 'Board',
21
+ description: 'The board to list posts for (optional — omit to list across all boards).',
22
+ required: false,
23
+ refreshers: [],
24
+ options: async ({ auth }) => {
25
+ if (!auth) {
26
+ return {
27
+ disabled: true,
28
+ placeholder: 'Connect your Canny account first.',
29
+ options: [],
30
+ };
31
+ }
32
+
33
+ const response = await cannyRequest<{
34
+ boards: Array<{ id: string; name: string }>;
35
+ }>({
36
+ apiKey: auth.secret_text,
37
+ path: '/boards/list',
38
+ });
39
+
40
+ return {
41
+ disabled: false,
42
+ options: (response.boards ?? []).map((b) => ({
43
+ label: b.name,
44
+ value: b.id,
45
+ })),
46
+ };
47
+ },
48
+ }),
49
+ search: Property.ShortText({
50
+ displayName: 'Search',
51
+ description: 'A search query to filter posts.',
52
+ required: false,
53
+ }),
54
+ sort: Property.StaticDropdown({
55
+ displayName: 'Sort',
56
+ description: 'The order of returned posts.',
57
+ required: false,
58
+ options: {
59
+ disabled: false,
60
+ options: [
61
+ { label: 'Newest', value: 'newest' },
62
+ { label: 'Oldest', value: 'oldest' },
63
+ { label: 'Score', value: 'score' },
64
+ { label: 'Trending', value: 'trending' },
65
+ { label: 'Status Changed', value: 'statusChanged' },
66
+ ],
67
+ },
68
+ }),
69
+ status: Property.ShortText({
70
+ displayName: 'Status',
71
+ description: 'Comma-separated list of statuses to filter by (e.g. "open,planned").',
72
+ required: false,
73
+ }),
74
+ limit: Property.Number({
75
+ displayName: 'Limit',
76
+ description: 'Number of posts to fetch (default 10).',
77
+ required: false,
78
+ }),
79
+ skip: Property.Number({
80
+ displayName: 'Skip',
81
+ description: 'Number of posts to skip (for pagination).',
82
+ required: false,
83
+ }),
84
+ },
85
+ async run({ auth, propsValue }) {
86
+ return await cannyRequest({
87
+ apiKey: auth.secret_text,
88
+ path: '/posts/list',
89
+ body: cleanBody({
90
+ boardID: propsValue.boardID,
91
+ search: propsValue.search,
92
+ sort: propsValue.sort,
93
+ status: propsValue.status,
94
+ limit: propsValue.limit,
95
+ skip: propsValue.skip,
96
+ }),
97
+ });
98
+ },
99
+ });
@@ -0,0 +1,31 @@
1
+ import { createAction, Property } from '@agnocon/pieces-framework';
2
+
3
+ import { cannyAuth } from '../auth';
4
+ import { cannyRequest } from '../common/client';
5
+
6
+ export const retrievePostAction = createAction({
7
+ auth: cannyAuth,
8
+ name: 'retrieve_post',
9
+ displayName: 'Retrieve Post',
10
+ description: 'Retrieves the details of an existing post by its ID.',
11
+ audience: 'both',
12
+ aiMetadata: {
13
+ description:
14
+ 'Looks up a single Canny post by its unique post ID and returns its full details. Use when you already have a post ID and need its current data (status, score, author, board). Read-only and idempotent.',
15
+ idempotent: true,
16
+ },
17
+ props: {
18
+ id: Property.ShortText({
19
+ displayName: 'Post ID',
20
+ description: 'The unique identifier of the post.',
21
+ required: true,
22
+ }),
23
+ },
24
+ async run({ auth, propsValue }) {
25
+ return await cannyRequest({
26
+ apiKey: auth.secret_text,
27
+ path: '/posts/retrieve',
28
+ body: { id: propsValue.id },
29
+ });
30
+ },
31
+ });
@@ -0,0 +1,34 @@
1
+ import { PieceAuth } from '@agnocon/pieces-framework';
2
+
3
+ import { cannyRequest } from './common/client';
4
+
5
+ export const cannyAuth = PieceAuth.SecretText({
6
+ displayName: 'API Key',
7
+ description:
8
+ 'Your Canny secret API key. Find it at Settings → API in your Canny admin dashboard.',
9
+ required: true,
10
+ validate: async ({ auth }) => {
11
+ try {
12
+ const response = await cannyRequest<{
13
+ boards?: Array<{ id: string }>;
14
+ }>({
15
+ apiKey: auth,
16
+ path: '/boards/list',
17
+ });
18
+
19
+ if (Array.isArray(response.boards)) {
20
+ return { valid: true };
21
+ }
22
+
23
+ return {
24
+ valid: false,
25
+ error: 'Unexpected response from Canny API. Check your API key.',
26
+ };
27
+ } catch {
28
+ return {
29
+ valid: false,
30
+ error: 'Invalid API key or could not reach the Canny API.',
31
+ };
32
+ }
33
+ },
34
+ });
@@ -0,0 +1,44 @@
1
+ import { HttpMethod, httpClient } from '@agnocon/pieces-common';
2
+
3
+ export const CANNY_API_BASE = 'https://canny.io/api/v1';
4
+
5
+ /**
6
+ * All Canny API endpoints use POST with JSON body.
7
+ * Authentication is via an `apiKey` field in the request body.
8
+ */
9
+ export async function cannyRequest<T = unknown>({
10
+ apiKey,
11
+ path,
12
+ body,
13
+ }: {
14
+ apiKey: string;
15
+ path: string;
16
+ body?: Record<string, unknown>;
17
+ }): Promise<T> {
18
+ const response = await httpClient.sendRequest<T>({
19
+ method: HttpMethod.POST,
20
+ url: `${CANNY_API_BASE}${path}`,
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ body: {
25
+ apiKey: apiKey.trim(),
26
+ ...body,
27
+ },
28
+ });
29
+
30
+ return response.body;
31
+ }
32
+
33
+ /**
34
+ * Strip undefined/null/empty-string values from an object before sending.
35
+ */
36
+ export function cleanBody(
37
+ obj: Record<string, unknown>,
38
+ ): Record<string, unknown> {
39
+ return Object.fromEntries(
40
+ Object.entries(obj).filter(
41
+ ([, v]) => v !== undefined && v !== null && v !== '',
42
+ ),
43
+ );
44
+ }
@@ -0,0 +1,36 @@
1
+ import { Property } from '@agnocon/pieces-framework';
2
+
3
+ import { cannyAuth } from '../auth';
4
+ import { cannyRequest } from './client';
5
+
6
+ export const boardIdProp = Property.Dropdown({
7
+ auth: cannyAuth,
8
+ displayName: 'Board',
9
+ description: 'The board to use.',
10
+ required: true,
11
+ refreshers: [],
12
+ options: async ({ auth }) => {
13
+ if (!auth) {
14
+ return {
15
+ disabled: true,
16
+ placeholder: 'Connect your Canny account first.',
17
+ options: [],
18
+ };
19
+ }
20
+
21
+ const response = await cannyRequest<{
22
+ boards: Array<{ id: string; name: string }>;
23
+ }>({
24
+ apiKey: auth.secret_text,
25
+ path: '/boards/list',
26
+ });
27
+
28
+ return {
29
+ disabled: false,
30
+ options: (response.boards ?? []).map((b) => ({
31
+ label: b.name,
32
+ value: b.id,
33
+ })),
34
+ };
35
+ },
36
+ });
@@ -0,0 +1,48 @@
1
+ import { createCannyTrigger } from './register-trigger';
2
+
3
+ export const newCommentTrigger = createCannyTrigger({
4
+ name: 'new_comment',
5
+ displayName: 'New Comment',
6
+ description: 'Triggers when a new comment is created on a post.',
7
+ aiMetadata: {
8
+ description:
9
+ 'Fires when a new comment is added to any Canny post, delivering the comment along with its author and parent post. Use to react to discussion activity on feedback.',
10
+ },
11
+ eventType: 'comment.created',
12
+ sampleData: {
13
+ created: '2026-04-06T11:05:00.000Z',
14
+ objectType: 'comment',
15
+ type: 'comment.created',
16
+ object: {
17
+ id: 'comment123',
18
+ value: 'This is a great idea! We really need this feature.',
19
+ created: '2026-04-06T11:05:00.000Z',
20
+ by: null,
21
+ author: {
22
+ id: 'author456',
23
+ name: 'John Smith',
24
+ email: 'john@example.com',
25
+ isAdmin: false,
26
+ avatarURL: null,
27
+ created: '2026-01-08T07:26:38.452Z',
28
+ url: 'https://yourcompany.canny.io/admin/users/john-smith',
29
+ userID: null,
30
+ },
31
+ post: {
32
+ id: 'post123',
33
+ title: 'Sample feature request',
34
+ status: 'open',
35
+ score: 5,
36
+ created: '2026-04-06T10:35:58.342Z',
37
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests/p/sample-feature-request',
38
+ board: {
39
+ id: 'board123',
40
+ name: 'Feature Requests',
41
+ postCount: 5,
42
+ created: '2026-04-06T10:35:08.905Z',
43
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests',
44
+ },
45
+ },
46
+ },
47
+ },
48
+ });
@@ -0,0 +1,52 @@
1
+ import { createCannyTrigger } from './register-trigger';
2
+
3
+ export const newPostTrigger = createCannyTrigger({
4
+ name: 'new_post',
5
+ displayName: 'New Post',
6
+ description: 'Triggers when a new post is created on a Canny board.',
7
+ aiMetadata: {
8
+ description:
9
+ 'Fires when a new feedback post is created on any Canny board, delivering the post with its board and author. Use to react to incoming feature requests or feedback as they are submitted.',
10
+ },
11
+ eventType: 'post.created',
12
+ sampleData: {
13
+ created: '2026-04-06T10:35:59.055Z',
14
+ objectType: 'post',
15
+ type: 'post.created',
16
+ object: {
17
+ id: 'post123',
18
+ title: 'Sample feature request',
19
+ details: 'It would be great if the app had dark mode.',
20
+ status: 'open',
21
+ score: 1,
22
+ commentCount: 0,
23
+ created: '2026-04-06T10:35:58.342Z',
24
+ statusChangedAt: '2026-04-06T10:35:58.342Z',
25
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests/p/sample-feature-request',
26
+ eta: null,
27
+ by: null,
28
+ category: null,
29
+ owner: null,
30
+ tags: [],
31
+ imageURLs: [],
32
+ customFields: [],
33
+ board: {
34
+ id: 'board123',
35
+ name: 'Feature Requests',
36
+ postCount: 1,
37
+ created: '2026-04-06T10:35:08.905Z',
38
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests',
39
+ },
40
+ author: {
41
+ id: 'author123',
42
+ name: 'Jane Doe',
43
+ email: 'jane@example.com',
44
+ isAdmin: false,
45
+ avatarURL: null,
46
+ created: '2026-01-08T07:26:38.452Z',
47
+ url: 'https://yourcompany.canny.io/admin/users/jane-doe',
48
+ userID: null,
49
+ },
50
+ },
51
+ },
52
+ });
@@ -0,0 +1,60 @@
1
+ import { createCannyTrigger } from './register-trigger';
2
+
3
+ export const newVoteTrigger = createCannyTrigger({
4
+ name: 'new_vote',
5
+ displayName: 'New Vote',
6
+ description: 'Triggers when a user votes on a post.',
7
+ aiMetadata: {
8
+ description:
9
+ 'Fires when a user casts a vote on a Canny post, delivering the vote with its voter and the parent post. Use to react to changes in demand or support for feedback.',
10
+ },
11
+ eventType: 'vote.created',
12
+ sampleData: {
13
+ created: '2026-04-06T10:47:47.056Z',
14
+ objectType: 'vote',
15
+ type: 'vote.created',
16
+ object: {
17
+ id: 'vote123',
18
+ created: '2026-04-06T10:47:46.858Z',
19
+ score: 1,
20
+ votePriority: 'No priority',
21
+ by: null,
22
+ voter: {
23
+ id: 'user123',
24
+ name: 'Jane Doe',
25
+ email: 'jane@example.com',
26
+ isAdmin: false,
27
+ avatarURL: null,
28
+ created: '2026-01-08T07:26:38.452Z',
29
+ url: 'https://yourcompany.canny.io/admin/users/jane-doe',
30
+ userID: null,
31
+ },
32
+ post: {
33
+ id: 'post123',
34
+ title: 'Sample feature request',
35
+ details: 'It would be great if the app had dark mode.',
36
+ status: 'open',
37
+ score: 1,
38
+ created: '2026-04-06T10:35:58.342Z',
39
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests/p/sample-feature-request',
40
+ board: {
41
+ id: 'board123',
42
+ name: 'Feature Requests',
43
+ postCount: 1,
44
+ created: '2026-04-06T10:35:08.905Z',
45
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests',
46
+ },
47
+ author: {
48
+ id: 'author123',
49
+ name: 'John Smith',
50
+ email: 'john@example.com',
51
+ isAdmin: false,
52
+ avatarURL: null,
53
+ created: '2026-01-08T07:26:38.452Z',
54
+ url: 'https://yourcompany.canny.io/admin/users/john-smith',
55
+ userID: null,
56
+ },
57
+ },
58
+ },
59
+ },
60
+ });
@@ -0,0 +1,52 @@
1
+ import { createCannyTrigger } from './register-trigger';
2
+
3
+ export const postStatusChangedTrigger = createCannyTrigger({
4
+ name: 'post_status_changed',
5
+ displayName: 'Post Status Changed',
6
+ description: "Triggers when a post's status is changed (e.g. open → in progress → complete).",
7
+ aiMetadata: {
8
+ description:
9
+ 'Fires when a Canny post moves to a different status (for example open to in progress to complete), delivering the post in its new state. Use to react to changes in how a feature request is progressing.',
10
+ },
11
+ eventType: 'post.status_changed',
12
+ sampleData: {
13
+ created: '2026-04-06T11:00:00.000Z',
14
+ objectType: 'post',
15
+ type: 'post.status_changed',
16
+ object: {
17
+ id: 'post123',
18
+ title: 'Sample feature request',
19
+ details: 'It would be great if the app had dark mode.',
20
+ status: 'in progress',
21
+ score: 5,
22
+ commentCount: 2,
23
+ created: '2026-04-06T10:35:58.342Z',
24
+ statusChangedAt: '2026-04-06T11:00:00.000Z',
25
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests/p/sample-feature-request',
26
+ eta: null,
27
+ by: null,
28
+ category: null,
29
+ owner: null,
30
+ tags: [],
31
+ imageURLs: [],
32
+ customFields: [],
33
+ board: {
34
+ id: 'board123',
35
+ name: 'Feature Requests',
36
+ postCount: 5,
37
+ created: '2026-04-06T10:35:08.905Z',
38
+ url: 'https://yourcompany.canny.io/admin/board/feature-requests',
39
+ },
40
+ author: {
41
+ id: 'author123',
42
+ name: 'Jane Doe',
43
+ email: 'jane@example.com',
44
+ isAdmin: false,
45
+ avatarURL: null,
46
+ created: '2026-01-08T07:26:38.452Z',
47
+ url: 'https://yourcompany.canny.io/admin/users/jane-doe',
48
+ userID: null,
49
+ },
50
+ },
51
+ },
52
+ });
@@ -0,0 +1,50 @@
1
+ import { createTrigger, TriggerStrategy } from '@agnocon/pieces-framework';
2
+ import { WebhookHandshakeStrategy } from '@agnocon/pieces-framework';
3
+
4
+ import { cannyAuth } from '../auth';
5
+
6
+ export type CannyEventType =
7
+ | 'post.created'
8
+ | 'post.status_changed'
9
+ | 'comment.created'
10
+ | 'vote.created';
11
+
12
+ export function createCannyTrigger({
13
+ name,
14
+ displayName,
15
+ description,
16
+ aiMetadata,
17
+ eventType,
18
+ sampleData,
19
+ }: {
20
+ name: string;
21
+ displayName: string;
22
+ description: string;
23
+ aiMetadata: { description: string };
24
+ eventType: CannyEventType;
25
+ sampleData: unknown;
26
+ }) {
27
+ return createTrigger({
28
+ auth: cannyAuth,
29
+ name,
30
+ displayName,
31
+ description,
32
+ aiMetadata,
33
+ props: {},
34
+ sampleData,
35
+ type: TriggerStrategy.WEBHOOK,
36
+ handshakeConfiguration: { strategy: WebhookHandshakeStrategy.NONE },
37
+ async onEnable(_context) {
38
+ // Canny webhooks must be configured manually in the Canny dashboard.
39
+ // Point the webhook URL to this trigger's webhook URL.
40
+ },
41
+ async onDisable(_context) {
42
+ // Nothing to clean up — webhooks are managed manually in Canny.
43
+ },
44
+ async run(context) {
45
+ const payload = context.payload.body as { type?: string };
46
+ if (payload?.type !== eventType) return [];
47
+ return [payload];
48
+ },
49
+ });
50
+ }