├── .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 | ![collabland-hello-action](./docs/collabland-hello-action.png) 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 |

OpenAPI spec: /openapi.json

78 |

Metadata: /hello-action/metadata

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 | --------------------------------------------------------------------------------