├── .prettierignore
├── public
├── favicon.ico
└── index.html
├── docs
└── collabland-hello-action.png
├── .vscode
└── settings.json
├── .prettierrc
├── .mocharc.cjs
├── tsconfig.json
├── src
├── index.ts
├── component.ts
├── application.ts
├── __tests__
│ └── acceptance
│ │ ├── hello-action-ed25519.acceptance.ts
│ │ └── hello-action-ecdsa.acceptance.ts
├── server.ts
├── client.ts
└── actions
│ └── hello-action.controller.ts
├── .github
└── workflows
│ └── build.yaml
├── .gitignore
├── LICENSE
├── renovate.json
├── README.md
└── package.json
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abridged/collabland-hello-action/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/docs/collabland-hello-action.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abridged/collabland-hello-action/HEAD/docs/collabland-hello-action.png
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.organizeImports": true
4 | },
5 | "editor.formatOnSave": true
6 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": false,
3 | "singleQuote": true,
4 | "printWidth": 80,
5 | "trailingComma": "all",
6 | "arrowParens": "avoid",
7 | "proseWrap": "always"
8 | }
9 |
--------------------------------------------------------------------------------
/.mocharc.cjs:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | module.exports = {
7 | exit: true,
8 | recursive: true,
9 | 'enable-source-maps': true,
10 | timeout: 30000,
11 | parallel: true,
12 | };
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/tsconfig",
3 | "extends": "@loopback/build/config/tsconfig.common.json",
4 | "compilerOptions": {
5 | "outDir": "dist",
6 | "rootDir": "src",
7 | "composite": true,
8 | "module": "NodeNext",
9 | "target": "ES2022",
10 | "useDefineForClassFields": false,
11 | "moduleResolution": "nodenext"
12 | },
13 | "include": ["src"]
14 | }
15 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {isMain} from '@collabland/common';
7 | import {main} from './server.js';
8 |
9 | export * from './component.js';
10 |
11 | if (isMain(import.meta.url)) {
12 | await main();
13 | }
14 |
--------------------------------------------------------------------------------
/src/component.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {Component} from '@loopback/core';
7 | import {HelloActionController} from './actions/hello-action.controller.js';
8 |
9 | /**
10 | * Register all services including command handlers, job runners and services
11 | */
12 | export class HelloActionComponent implements Component {
13 | controllers = [HelloActionController];
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: Build @collabland/example-hello-action
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - master
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | node-version: ['16', '18']
17 |
18 | steps:
19 | - uses: actions/checkout@v3
20 | - name: Use Node.js ${{ matrix.node-version }}
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | check-latest: true
25 |
26 | - run: npm ci
27 | - run: npm test
28 |
--------------------------------------------------------------------------------
/src/application.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {getEnvVar, getEnvVarAsNumber} from '@collabland/common';
7 | import {ApplicationConfig} from '@loopback/core';
8 | import {RestApplication} from '@loopback/rest';
9 | import {fileURLToPath} from 'url';
10 | import {HelloActionComponent} from './component.js';
11 |
12 | /**
13 | * A demo application to expose REST APIs for Hello action
14 | */
15 | export class HelloActionApplication extends RestApplication {
16 | constructor(config?: ApplicationConfig) {
17 | super(HelloActionApplication.resolveConfig(config));
18 | this.component(HelloActionComponent);
19 | const dir = fileURLToPath(new URL('../public', import.meta.url));
20 | this.static('/', dir);
21 | }
22 |
23 | private static resolveConfig(config?: ApplicationConfig): ApplicationConfig {
24 | return {
25 | ...config,
26 | rest: {
27 | port: getEnvVarAsNumber('PORT', 3000),
28 | host: getEnvVar('HOST'),
29 | ...config?.rest,
30 | },
31 | };
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (http://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # Typescript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # Transpiled JavaScript files from Typescript
61 | /dist
62 |
63 | # Cache used by TypeScript's incremental build
64 | *.tsbuildinfo
65 |
66 | src/types
67 |
68 | .idea/
69 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Abridged, Inc. 2023.
2 | Node module: @collabland/example-hello-action
3 | This project is licensed under the MIT License, full text below.
4 |
5 | --------
6 |
7 | MIT License
8 |
9 | Copyright (c) Abridged, Inc. 2023
10 |
11 | Permission is hereby granted, free of charge, to any person obtaining a copy of
12 | this software and associated documentation files (the "Software"), to deal in
13 | the Software without restriction, including without limitation the rights to
14 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
15 | the Software, and to permit persons to whom the Software is furnished to do so,
16 | subject to the following conditions:
17 |
18 | The above copyright notice and this permission notice shall be included in all
19 | copies or substantial portions of the Software.
20 |
21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
23 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
24 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
25 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
26 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | ":gitSignOff",
4 | "group:monorepos",
5 | "group:recommended",
6 | "helpers:disableTypesNodeMajor"
7 | ],
8 |
9 | "ignorePaths": ["**/node_modules/**", "**/__tests__/**", "**/test/**"],
10 |
11 | "automerge": false,
12 | "branchPrefix": "renovate/",
13 | "ignoreUnstable": true,
14 | "statusCheckVerify": true,
15 | "updateNotScheduled": true,
16 |
17 | "lockFileMaintenance": {
18 | "enabled": true,
19 | "schedule": "before 5am on monday"
20 | },
21 |
22 | "prConcurrentLimit": 20,
23 | "prCreation": "immediate",
24 | "prHourlyLimit": 2,
25 |
26 | "semanticCommits": true,
27 | "semanticCommitType": "chore",
28 | "semanticCommitScope": null,
29 |
30 | "separateMajorMinor": false,
31 | "separateMinorPatch": false,
32 |
33 | "packageRules": [
34 | {
35 | "matchPackagePrefixes": ["@collabland/"],
36 | "groupName": "collabland packages"
37 | },
38 | {
39 | "matchPackagePrefixes": ["@loopback/"],
40 | "groupName": "loopback packages"
41 | },
42 | {
43 | "updateTypes": ["minor", "patch"],
44 | "automerge": true
45 | },
46 | {
47 | "depTypeList": ["devDependencies"],
48 | "automerge": true
49 | }
50 | ],
51 |
52 | "masterIssue": true,
53 | "masterIssueApproval": false,
54 | "masterIssueAutoclose": true
55 | }
56 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @collabland/example-hello-action
2 |
3 | This example illustrates how to implement a Collab Action for Discord
4 | interactions.
5 |
6 | ## Architectural diagram
7 |
8 | 
9 |
10 | ### Prerequisites
11 |
12 | Node.js and npm (Node Package Manager) must be installed on your system.
13 |
14 | ## Get started
15 |
16 | Let's try out this `/hello-action` example to see how what Collab Action is
17 | capable of!
18 |
19 | ### Clone the Action
20 |
21 | ```bash
22 | git clone https://github.com/abridged/collabland-hello-action.git
23 | ```
24 | ### Navigate to the project directory and install the project dependencies
25 |
26 | ```bash
27 | cd collabland-hello-action
28 | npm install
29 | ```
30 |
31 | ### Build the project
32 |
33 | ```bash
34 | npm run build
35 | ```
36 | Next, follow the instructions on the docs to test the Action
37 |
38 | 1. [Run the Action Server](https://dev.collab.land/docs/upstream-integrations/collab-actions/getting-started-with-collab-actions#run-the-action-server-locally)
39 |
40 | ```bash
41 | npm run server -- DhF7T98EBmH1ZFmdGJvBhkmdn3BfAqc3tz8LxER8VH2q
42 | ```
43 |
44 | Next, once you get to play with this simple action, try to implement something new and test it locally
45 |
46 | 2. [Implement & test your Action locally](https://dev.collab.land/docs/upstream-integrations/collab-actions/getting-started-with-collab-actions#test-the-actions-in-a-discord-server)
47 |
48 | Explore more possibilities with Collab Action!
49 |
50 | 3. [Build a custom Collab Action](https://dev.collab.land/docs/upstream-integrations/collab-actions/getting-started-with-collab-actions)
51 |
--------------------------------------------------------------------------------
/src/__tests__/acceptance/hello-action-ed25519.acceptance.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {expect, givenHttpServerConfig} from '@loopback/testlab';
7 | import {HelloActionApplication} from '../../application.js';
8 | import {main as client} from '../../client.js';
9 | import {main} from '../../server.js';
10 |
11 | describe('HelloAction - ed25519', () => {
12 | let app: HelloActionApplication;
13 | let signingKey: string;
14 |
15 | before('setupApplication', async () => {
16 | const restConfig = givenHttpServerConfig({});
17 | ({app, signingKey} = await main({rest: restConfig}, 'ed25519'));
18 | });
19 |
20 | after(async () => {
21 | await app.stop();
22 | });
23 |
24 | it('invokes action with ecdsa signature', async () => {
25 | const result = await client(
26 | app.restServer.url + '/hello-action',
27 | 'ed25519:' + signingKey,
28 | );
29 | expect(result.metadata.applicationCommands).to.eql([
30 | {
31 | metadata: {
32 | name: 'HelloAction',
33 | shortName: 'hello-action',
34 | supportedEnvs: ['dev', 'qa', 'staging'],
35 | },
36 | name: 'hello-action',
37 | type: 1,
38 | description: '/hello-action',
39 | options: [
40 | {
41 | name: 'your-name',
42 | description: "Name of person we're greeting",
43 | type: 3,
44 | required: true,
45 | },
46 | ],
47 | },
48 | ]);
49 | expect(result.response).to.eql({
50 | type: 4,
51 | data: {content: 'Hello, John!', flags: 64},
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CollabLand Hello Action Server
6 |
7 |
8 |
9 |
10 |
11 |
12 |
70 |
71 |
72 |
73 |
74 |
CollabLand Hello Action Server
75 |
Version 1.0.0
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@collabland/example-hello-action",
3 | "version": "0.0.1",
4 | "description": "CollabLand Hello action",
5 | "keywords": [
6 | "CollabLand",
7 | "Collab.Land",
8 | "action",
9 | "Discord",
10 | "loopback"
11 | ],
12 | "type": "module",
13 | "main": "dist/index.js",
14 | "types": "dist/index.d.ts",
15 | "scripts": {
16 | "build": "lb-tsc -b",
17 | "build:watch": "lb-tsc -b --watch",
18 | "build:full": "npm ci && npm run rebuild && npm run test:dev",
19 | "prettier:cli": "lb-prettier \"**/*.ts\" \"**/*.md\"",
20 | "prettier:check": "npm run prettier:cli -- -l",
21 | "prettier:fix": "npm run prettier:cli -- --write",
22 | "pretest": "npm run build",
23 | "test": "lb-mocha --config .mocharc.cjs --allow-console-logs \"dist/__tests__\"",
24 | "rebuild": "npm run clean && npm run build",
25 | "clean": "lb-clean dist *.tsbuildinfo .eslintcache",
26 | "start": "npm run rebuild && node dist/server",
27 | "server": "node dist/server DhF7T98EBmH1ZFmdGJvBhkmdn3BfAqc3tz8LxER8VH2q",
28 | "client": "node dist/client"
29 | },
30 | "repository": {
31 | "type": "git",
32 | "url": "git@github.com:abridged/collabland-hello-action.git"
33 | },
34 | "publishConfig": {
35 | "access": "public"
36 | },
37 | "license": "MIT",
38 | "files": [
39 | "README.md",
40 | "dist",
41 | "src",
42 | "!*/__tests__"
43 | ],
44 | "dependencies": {
45 | "@collabland/action": "^0.11.0",
46 | "@collabland/common": "^0.50.0",
47 | "@collabland/discord": "^0.29.0",
48 | "@collabland/models": "^0.30.0",
49 | "@loopback/core": "^5.1.0",
50 | "@loopback/rest": "^13.1.0",
51 | "discord-api-types": "^0.38.0",
52 | "discord.js": "^14.11.0",
53 | "tslib": "^2.0.0"
54 | },
55 | "devDependencies": {
56 | "@loopback/build": "^10.1.0",
57 | "@loopback/eslint-config": "^14.0.1",
58 | "@loopback/testlab": "^6.1.0",
59 | "@types/node": "^18.11.15",
60 | "typescript": "~5.9.0"
61 | },
62 | "copyright.owner": "Abridged, Inc.",
63 | "author": "Abridged, Inc."
64 | }
65 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {
7 | ActionSignatureType,
8 | generateEcdsaKeyPair,
9 | generateEd25519KeyPair,
10 | } from '@collabland/action';
11 | import {getEnvVar, isMain, setEnvVar} from '@collabland/common';
12 | import {ApplicationConfig} from '@loopback/core';
13 | import {HelloActionApplication} from './application.js';
14 |
15 | export async function main(
16 | config: ApplicationConfig = {},
17 | signatureType?: ActionSignatureType,
18 | ) {
19 | const publicKey =
20 | signatureType ??
21 | process.argv[2] ??
22 | getEnvVar('COLLABLAND_ACTION_PUBLIC_KEY');
23 | let signingKey = '';
24 | if (publicKey == null || publicKey === 'ecdsa' || publicKey === 'ed25519') {
25 | const sigType = publicKey ?? 'ed25519';
26 | switch (sigType) {
27 | case 'ecdsa': {
28 | const keyPair = generateEcdsaKeyPair();
29 | signingKey = keyPair.privateKey;
30 | setEnvVar(
31 | 'COLLABLAND_ACTION_PUBLIC_KEY',
32 | 'ecdsa:' + keyPair.publicKey,
33 | true,
34 | );
35 | if (config.rest == null) {
36 | console.log('Action signing key: %s', `${sigType}:${signingKey}`);
37 | }
38 | break;
39 | }
40 | case 'ed25519': {
41 | const keyPair = generateEd25519KeyPair();
42 | signingKey = keyPair.privateKey;
43 | setEnvVar(
44 | 'COLLABLAND_ACTION_PUBLIC_KEY',
45 | 'ed25519:' + keyPair.publicKey,
46 | true,
47 | );
48 | if (config.rest == null) {
49 | console.log('Action signing key: %s', `${sigType}:${signingKey}`);
50 | }
51 | break;
52 | }
53 | default: {
54 | throw new Error(
55 | `Signature type not supported: ${sigType}. Please use ecdsa or ed25519.`,
56 | );
57 | }
58 | }
59 | } else {
60 | // Set the public key
61 | setEnvVar('COLLABLAND_ACTION_PUBLIC_KEY', publicKey, true);
62 | }
63 | const app = new HelloActionApplication(config);
64 | await app.start();
65 |
66 | const url = app.restServer.url;
67 | if (config.rest == null) {
68 | console.log(`HelloWorld action is running at ${url}`);
69 | }
70 | return {app, signingKey};
71 | }
72 |
73 | if (isMain(import.meta.url)) {
74 | await main();
75 | }
76 |
--------------------------------------------------------------------------------
/src/__tests__/acceptance/hello-action-ecdsa.acceptance.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {
7 | ActionEcdsaSignatureHeader,
8 | ActionSignatureTimestampHeader,
9 | } from '@collabland/action';
10 | import {getFetch} from '@collabland/common';
11 | import {expect, givenHttpServerConfig} from '@loopback/testlab';
12 | import {HelloActionApplication} from '../../application.js';
13 | import {MOCKED_INTERACTION, main as client} from '../../client.js';
14 | import {main as server} from '../../server.js';
15 |
16 | describe('HelloAction - ecdsa', () => {
17 | const body = JSON.stringify(MOCKED_INTERACTION);
18 | let app: HelloActionApplication;
19 | let signingKey: string;
20 |
21 | before('setupApplication', async () => {
22 | const restConfig = givenHttpServerConfig({});
23 | ({app, signingKey} = await server({rest: restConfig}, 'ecdsa'));
24 | });
25 |
26 | after(async () => {
27 | await app.stop();
28 | });
29 |
30 | it('reports error if signature is missing', async () => {
31 | const fetch = getFetch();
32 | const res = await fetch(app.restServer.url + '/hello-action/interactions', {
33 | method: 'post',
34 | body,
35 | headers: {
36 | [ActionSignatureTimestampHeader]: Date.now().toString(),
37 | },
38 | });
39 | expect(res.status).to.eql(400);
40 | });
41 |
42 | it('reports error if timestamp is missing', async () => {
43 | const fetch = getFetch();
44 | const res = await fetch(app.restServer.url + '/hello-action/interactions', {
45 | method: 'post',
46 | body,
47 | headers: {
48 | [ActionEcdsaSignatureHeader]: 'dummy-signature',
49 | },
50 | });
51 | expect(res.status).to.eql(400);
52 | });
53 |
54 | it('reports error if signature is invalid', async () => {
55 | const fetch = getFetch();
56 | const res = await fetch(app.restServer.url + '/hello-action/interactions', {
57 | method: 'post',
58 | body,
59 | headers: {
60 | [ActionSignatureTimestampHeader]: Date.now().toString(),
61 | [ActionEcdsaSignatureHeader]: 'dummy-signature',
62 | },
63 | });
64 | expect(res.status).to.eql(401);
65 | });
66 |
67 | it('invokes action with ecdsa signature', async () => {
68 | const result = await client(
69 | app.restServer.url + '/hello-action',
70 | signingKey,
71 | );
72 | expect(result.metadata.applicationCommands).to.eql([
73 | {
74 | metadata: {
75 | name: 'HelloAction',
76 | shortName: 'hello-action',
77 | supportedEnvs: ['dev', 'qa', 'staging'],
78 | },
79 | name: 'hello-action',
80 | type: 1,
81 | description: '/hello-action',
82 | options: [
83 | {
84 | name: 'your-name',
85 | description: "Name of person we're greeting",
86 | type: 3,
87 | required: true,
88 | },
89 | ],
90 | },
91 | ]);
92 | expect(result.response).to.eql({
93 | type: 4,
94 | data: {content: 'Hello, John!', flags: 64},
95 | });
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/src/client.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {
7 | getFetch,
8 | handleFetchResponse,
9 | isMain,
10 | stringify,
11 | } from '@collabland/common';
12 | import {
13 | APIChatInputApplicationCommandInteraction,
14 | APIInteractionResponse,
15 | ApplicationCommandOptionType,
16 | ApplicationCommandType,
17 | ChannelType,
18 | GuildMemberFlags,
19 | InteractionType,
20 | } from 'discord.js';
21 |
22 | import {
23 | getActionKeyAndType,
24 | getActionPrivateKey,
25 | invokeWebhook,
26 | } from '@collabland/action';
27 | import {DiscordActionMetadata, DiscordActionRequest} from '@collabland/discord';
28 |
29 | /**
30 | * The interaction simulates `/hello-action John`
31 | */
32 | export const MOCKED_INTERACTION: APIChatInputApplicationCommandInteraction = {
33 | app_permissions: '4398046511103',
34 | application_id: '715138531994894397',
35 | channel_id: '941347407302651955',
36 | data: {
37 | guild_id: '929214449733230592',
38 | id: '1063553299804078151',
39 | name: 'hello-action',
40 | options: [
41 | {
42 | name: 'your-name',
43 | type: ApplicationCommandOptionType.String,
44 | value: 'John',
45 | },
46 | ],
47 | type: ApplicationCommandType.ChatInput,
48 | },
49 | guild_id: '929214449733230592',
50 | guild_locale: 'en-US',
51 | id: '1064236313630482482', // interaction id
52 | locale: 'en-US',
53 | member: {
54 | avatar: null,
55 | communication_disabled_until: null,
56 | deaf: false,
57 | joined_at: '2022-01-08T03:26:28.791000+00:00',
58 | mute: false,
59 | nick: null,
60 | pending: false,
61 | permissions: '4398046511103',
62 | premium_since: null,
63 | roles: [],
64 | flags: GuildMemberFlags.CompletedOnboarding,
65 | user: {
66 | avatar: 'a_8a814f663844a69d22344dc8f4983de6',
67 | discriminator: '0000',
68 | id: '781898624464453642',
69 | username: 'Test User',
70 | global_name: 'testuser',
71 | },
72 | },
73 | channel: {
74 | id: '01',
75 | type: ChannelType.GuildText,
76 | },
77 | token: '', // interaction token intentionally removed by Collab.Land
78 | type: InteractionType.ApplicationCommand,
79 | version: 1,
80 | };
81 |
82 | export async function main(base?: string, signingKey?: string) {
83 | signingKey = signingKey ?? process.argv[2];
84 | const key =
85 | signingKey != null
86 | ? getActionKeyAndType(signingKey, true)
87 | : getActionPrivateKey();
88 |
89 | const interaction = MOCKED_INTERACTION;
90 | const fetch = getFetch();
91 | const url = base ?? 'http://localhost:3000/hello-action';
92 | const result = await fetch(`${url}/metadata`);
93 | const metadata = await handleFetchResponse(result);
94 | if (base == null) {
95 | console.log('Application commands: %s', stringify(metadata));
96 | }
97 | const response = await invokeWebhook<
98 | APIInteractionResponse,
99 | DiscordActionRequest
100 | >(`${url}/interactions`, interaction, key.key, key.type);
101 | if (base == null) {
102 | console.log('Discord interaction response: %s', stringify(response));
103 | }
104 | return {metadata, response};
105 | }
106 |
107 | if (isMain(import.meta.url)) {
108 | await main();
109 | }
110 |
--------------------------------------------------------------------------------
/src/actions/hello-action.controller.ts:
--------------------------------------------------------------------------------
1 | // Copyright Abridged, Inc. 2023. All Rights Reserved.
2 | // Node module: @collabland/example-hello-action
3 | // This file is licensed under the MIT License.
4 | // License text available at https://opensource.org/licenses/MIT
5 |
6 | import {
7 | EnvType,
8 | handleFetchResponse,
9 | loggers,
10 | sleep,
11 | stringify,
12 | } from '@collabland/common';
13 | import {
14 | APIChatInputApplicationCommandInteraction,
15 | APIInteractionResponse,
16 | ApplicationCommandOptionType,
17 | ApplicationCommandSpec,
18 | ApplicationCommandType,
19 | BaseDiscordActionController,
20 | DiscordActionMetadata,
21 | DiscordActionRequest,
22 | DiscordActionResponse,
23 | DiscordInteractionPattern,
24 | InteractionResponseType,
25 | InteractionType,
26 | MessageFlags,
27 | RESTPatchAPIWebhookWithTokenMessageJSONBody,
28 | RESTPostAPIWebhookWithTokenJSONBody,
29 | buildSimpleResponse,
30 | inspectUserPermissionsButton,
31 | parseApplicationCommand,
32 | } from '@collabland/discord';
33 | import {MiniAppManifest} from '@collabland/models';
34 | import {BindingScope, injectable} from '@loopback/core';
35 | import {api} from '@loopback/rest';
36 |
37 | const {debug} = loggers('collabland:example:hello-action');
38 |
39 | /**
40 | * HelloActionController is a LoopBack REST API controller that exposes endpoints
41 | * to support Collab.Land actions for Discord interactions.
42 | */
43 | @injectable({
44 | scope: BindingScope.SINGLETON,
45 | })
46 | @api({basePath: '/hello-action'}) // Set the base path to `/hello-action`
47 | export class HelloActionController extends BaseDiscordActionController {
48 | /**
49 | * Expose metadata for the action
50 | * @returns
51 | */
52 | async getMetadata(): Promise {
53 | const metadata: DiscordActionMetadata = {
54 | /**
55 | * Miniapp manifest
56 | */
57 | manifest: new MiniAppManifest({
58 | appId: 'hello-action',
59 | clientId: 'collabland_demo',
60 | developer: 'collab.land',
61 | supportedEnvs: [
62 | EnvType.QA,
63 | EnvType.TEST,
64 | EnvType.PROD,
65 | EnvType.DEV,
66 | EnvType.STAGING,
67 | ],
68 | name: 'HelloAction',
69 | platforms: ['discord'],
70 | shortName: 'hello-action',
71 | version: {name: '0.0.1'},
72 | website: 'https://collab.land',
73 | description: 'An example Collab.Land action',
74 | releasedDate: Math.floor(Date.now() / 1000), // secs
75 | shortDescription: 'An example Collab.Land action',
76 | thumbnails: [],
77 | price: 0,
78 | }),
79 | /**
80 | * Supported Discord interactions. They allow Collab.Land to route Discord
81 | * interactions based on the type and name/custom-id.
82 | */
83 | supportedInteractions: this.getSupportedInteractions(),
84 | /**
85 | * Supported Discord application commands. They will be registered to a
86 | * Discord guild upon installation.
87 | */
88 | applicationCommands: this.getApplicationCommands(),
89 | requiredContext: ['isCommunityAdmin', 'gmPassAddress', 'guildName'],
90 | };
91 | return metadata;
92 | }
93 |
94 | /**
95 | * Handle the Discord interaction
96 | * @param interaction - Discord interaction with Collab.Land action context
97 | * @returns - Discord interaction response
98 | */
99 | protected async handle(
100 | interaction: DiscordActionRequest,
101 | ): Promise {
102 | const userPerms = inspectUserPermissionsButton(interaction);
103 | debug('User permissions: %O', userPerms);
104 | if (userPerms != null) {
105 | const apiToken = userPerms.apiToken;
106 | if (apiToken != null && interaction.actionContext?.callbackUrl != null) {
107 | const url = new URL(interaction.actionContext?.callbackUrl);
108 | const res = await this.fetch(url.origin + '/account/me', {
109 | headers: {
110 | authorization: `Bearer ${apiToken}`,
111 | },
112 | });
113 | const user = await handleFetchResponse(res);
114 | console.log('User profile: %O', user);
115 | const task = async () => {
116 | await sleep(1000);
117 | await this.followupMessage(interaction, {content: stringify(user)});
118 | };
119 | task().catch(err => {
120 | console.error('Fail to send followup message: %O', err);
121 | });
122 | }
123 | return {
124 | type: InteractionResponseType.ChannelMessageWithSource,
125 | data: {
126 | flags: MessageFlags.Ephemeral,
127 | embeds: [
128 | {
129 | title: 'User response for permissions',
130 | fields: [
131 | {
132 | name: 'user',
133 | value:
134 | userPerms.user?.username +
135 | '#' +
136 | userPerms.user?.discriminator,
137 | },
138 | {
139 | name: 'action',
140 | value: userPerms.action,
141 | inline: true,
142 | },
143 | {
144 | name: 'interactionId',
145 | value: userPerms.interactionId,
146 | inline: true,
147 | },
148 | {
149 | name: 'scopes',
150 | value: userPerms.scopes.join(' '),
151 | inline: true,
152 | },
153 | ],
154 | },
155 | ],
156 | },
157 | };
158 | }
159 | /**
160 | * Get the value of `your-name` argument for `/hello-action`
161 | */
162 | const yourName = parseApplicationCommand(interaction).args['your-name'];
163 | const message = `Hello, ${
164 | yourName ?? interaction.user?.username ?? 'World'
165 | }!`;
166 | /**
167 | * Build a simple Discord message private to the user
168 | */
169 | const response: APIInteractionResponse = buildSimpleResponse(message, true);
170 | /**
171 | * Allow advanced followup messages
172 | */
173 | this.followup(interaction, message).catch(err => {
174 | console.error(
175 | 'Fail to send followup message to interaction %s: %O',
176 | interaction.id,
177 | err,
178 | );
179 | });
180 | // Return the 1st response to Discord
181 | return response;
182 | }
183 |
184 | private async followup(
185 | request: DiscordActionRequest,
186 | message: string,
187 | ) {
188 | const callback = request.actionContext?.callbackUrl;
189 | if (callback != null) {
190 | await this.requestUserPermissions(request, ['user:read', 'user:write']);
191 | const followupMsg: RESTPostAPIWebhookWithTokenJSONBody = {
192 | content: `Follow-up: **${message}**`,
193 | flags: MessageFlags.Ephemeral,
194 | };
195 | await sleep(1000);
196 | let msg = await this.followupMessage(request, followupMsg);
197 | await sleep(1000);
198 | // 5 seconds count down
199 | for (let i = 5; i > 0; i--) {
200 | const updated: RESTPatchAPIWebhookWithTokenMessageJSONBody = {
201 | content: `[${i}s]: **${message}**`,
202 | };
203 | msg = await this.editMessage(request, updated, msg?.id);
204 | await sleep(1000);
205 | }
206 | // Delete the follow-up message
207 | await this.deleteMessage(request, msg?.id);
208 | }
209 | }
210 |
211 | /**
212 | * Build a list of supported Discord interactions
213 | * @returns
214 | */
215 | private getSupportedInteractions(): DiscordInteractionPattern[] {
216 | return [
217 | {
218 | // Handle `/hello-action` slash command
219 | type: InteractionType.ApplicationCommand,
220 | names: ['hello-action'],
221 | commandType: ApplicationCommandType.ChatInput,
222 | },
223 | ];
224 | }
225 |
226 | /**
227 | * Build a list of Discord application commands. It's possible to use tools
228 | * like https://autocode.com/tools/discord/command-builder/.
229 | * @returns
230 | */
231 | private getApplicationCommands(): ApplicationCommandSpec[] {
232 | const commands: ApplicationCommandSpec[] = [
233 | // `/hello-action ` slash command
234 | {
235 | metadata: {
236 | name: 'HelloAction',
237 | shortName: 'hello-action',
238 | supportedEnvs: [
239 | EnvType.DEV,
240 | EnvType.QA,
241 | EnvType.STAGING,
242 | ],
243 | },
244 | name: 'hello-action',
245 | type: ApplicationCommandType.ChatInput,
246 | description: '/hello-action',
247 | options: [
248 | {
249 | name: 'your-name',
250 | description: "Name of person we're greeting",
251 | type: ApplicationCommandOptionType.String,
252 | required: true,
253 | },
254 | ],
255 | },
256 | ];
257 | return commands;
258 | }
259 | }
260 |
--------------------------------------------------------------------------------