Repository: hubotio/hubot Branch: main Commit: 9e76a4f9bdc8 Files: 96 Total size: 405.7 KB Directory structure: gitextract_19fif895/ ├── .editorconfig ├── .github/ │ ├── stale.yml │ └── workflows/ │ ├── nodejs-macos.yml │ ├── nodejs-ubuntu.yml │ ├── nodejs-windows.yml │ ├── pipeline.yml │ └── sfab-gh-pages.yml ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── bin/ │ ├── Hubot.mjs │ ├── e2e-test.sh │ └── hubot ├── configuration/ │ └── Config.mjs ├── docs/ │ ├── adapters/ │ │ ├── campfire.md │ │ ├── development.md │ │ └── shell.md │ ├── adapters.md │ ├── assets/ │ │ ├── fonts/ │ │ │ └── otf/ │ │ │ ├── octicons-regular-webfont.otf │ │ │ ├── style_154042.otf │ │ │ ├── style_154045.otf │ │ │ ├── style_154046.otf │ │ │ ├── style_154048.otf │ │ │ ├── style_154051.otf │ │ │ └── style_154053.otf │ │ └── stylesheets/ │ │ └── application.css │ ├── deploying/ │ │ ├── azure.md │ │ ├── bluemix.md │ │ ├── unix.md │ │ └── windows.md │ ├── deploying.md │ ├── designs/ │ │ └── commands.md │ ├── docs.md │ ├── implementation.md │ ├── index.html │ ├── layouts/ │ │ ├── docs.html │ │ └── main.html │ ├── patterns.md │ └── scripting.md ├── examples/ │ ├── hubot-start.ps1 │ └── hubot.service ├── index.mjs ├── package.json ├── script/ │ ├── bootstrap │ ├── release │ ├── server │ ├── simple-lint.mjs │ ├── smoke-test │ └── test ├── sfab-hooks/ │ └── SfabHook.mjs ├── src/ │ ├── Adapter.mjs │ ├── Brain.mjs │ ├── CommandBus.mjs │ ├── DataStore.mjs │ ├── GenHubot.mjs │ ├── HttpClient.mjs │ ├── Listener.mjs │ ├── Message.mjs │ ├── Middleware.mjs │ ├── OptParse.mjs │ ├── Response.mjs │ ├── Robot.mjs │ ├── User.mjs │ ├── adapters/ │ │ ├── Campfire.mjs │ │ └── Shell.mjs │ └── datastores/ │ └── Memory.mjs └── test/ ├── AdapterName_test.mjs ├── Adapter_test.mjs ├── Brain_test.mjs ├── CommandBus_test.mjs ├── Configuration_test.mjs ├── DataStore_test.mjs ├── Hubot_test.mjs ├── Listener_test.mjs ├── Message_test.mjs ├── Middleware_test.mjs ├── OptParse-test.mjs ├── Robot_test.mjs ├── Shell_test.mjs ├── User_test.mjs ├── XampleTest.mjs ├── doubles/ │ └── DummyAdapter.mjs ├── fixtures/ │ ├── MockAdapter.mjs │ ├── TestScript.js │ ├── TestScript.mjs │ ├── TestScript.ts │ ├── TestScriptIncorrectApi.js │ └── TestScriptIncorrectApi.mjs ├── index_test.mjs ├── ordered-scripts/ │ ├── 01-PFirst.mjs │ ├── 02-SetupBotConfig.mjs │ └── WebSetup.mjs └── scripts/ └── Xample.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig is awesome: http://EditorConfig.org # Top-most EditorConfig file root = true # Match and apply these rules for all file # types you open in your code editor [*] # Unix-style newlines end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: .github/stale.yml ================================================ # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale daysUntilStale: 90 # Number of days of inactivity before a stale Issue or Pull Request is closed daysUntilClose: 7 # Issues or Pull Requests with these labels will never be considered stale exemptLabels: - pinned - security # Label to use when marking as stale staleLabel: stale # Comment to post when marking as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has [not had recent activity](https://github.com/github/hubot/blob/main/CONTRIBUTING.md#stale-issue-and-pull-request-policy). It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when removing the stale label. Set to `false` to disable unmarkComment: false # Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable closeComment: false # Limit to only `issues` or `pulls` # only: issues ================================================ FILE: .github/workflows/nodejs-macos.yml ================================================ name: Node.js (macOS) CI permissions: contents: read issues: read on: push: branches: [ "main" ] schedule: - cron: '5 4 * * 0' jobs: npm-test: runs-on: macos-latest strategy: matrix: node-version: [23.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - run: npm test --experimental-strip-types ================================================ FILE: .github/workflows/nodejs-ubuntu.yml ================================================ name: Node.js (Ubuntu) CI permissions: contents: read issues: read on: push: branches: [ "main" ] schedule: - cron: '5 4 * * 0' jobs: npm-test: runs-on: ubuntu-latest strategy: matrix: node-version: [23.x, latest] steps: - uses: actions/checkout@v4 - name: Install expect run: sudo apt-get install expect - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - run: npm test --experimental-strip-types - run: npm run test:e2e ================================================ FILE: .github/workflows/nodejs-windows.yml ================================================ name: Node.js (Windows) CI permissions: contents: read issues: read on: push: branches: - main schedule: - cron: '5 4 * * 0' jobs: npm-test: runs-on: windows-latest strategy: matrix: node-version: [23.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - name: Run Tests env: HUBOT_LOG_LEVEL: debug run: npm test --experimental-strip-types ================================================ FILE: .github/workflows/pipeline.yml ================================================ name: Build and release pipeline permissions: contents: write issues: write pull-requests: write id-token: write on: push: branches: - main - next pull_request: branches: - main - next jobs: build: name: Build and Verify runs-on: ubuntu-latest strategy: matrix: node-version: - 20.x steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies run: npm audit signatures test: name: Tests runs-on: ubuntu-latest needs: build strategy: matrix: node-version: - 24.x steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - run: npm test -- --experimental-strip-types e2etest: name: E2E Test needs: - build - test runs-on: ubuntu-latest strategy: matrix: node-version: - 20.x steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install expect run: sudo apt-get install expect - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - run: npm run test:e2e release: name: Release if: github.ref == 'refs/heads/main' && success() needs: [build, test, e2etest] runs-on: ubuntu-latest strategy: matrix: node-version: - 22.14.0 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Semantic Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release ================================================ FILE: .github/workflows/sfab-gh-pages.yml ================================================ # Sample workflow for building and deploying a sfab site to GitHub Pages name: Deploy sfab with GitHub Pages dependencies preinstalled on: # Runs on pushes targeting the default branch push: branches: ["main"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: # Build job build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Pages uses: actions/configure-pages@v4 - name: Use Node.js and Build with sfab uses: actions/setup-node@v4 with: node-version: latest - run: npm run build - name: Upload artifact uses: actions/upload-pages-artifact@v3 # Deployment job deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ node_modules .hubot_history .node-version .nyc_output/ npm-debug.log coverage/ _site .env users.md .data ================================================ FILE: .npmignore ================================================ .editorconfig .github .hubot_history bin/e2e-test.sh test docs examples script www _site *.tgz configuration ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at support@github.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). Everyone is welcome to contribute to Hubot. Contributing doesn’t just mean submitting pull requests—there are many different ways for you to get involved, including answering questions, reporting or triaging [issues](https://github.com/github/hubot/issues), and participating in using Hubot. No matter how you want to get involved, we ask that you first learn what’s expected of anyone who participates in the project by reading the [Contributor Covenant Code of Conduct](http://contributor-covenant.org). By participating, you are expected to uphold this code. We love pull requests. Here's a quick guide: 1. If you're adding a new feature or changing user-facing APIs, check out the [Hubot Evolution](https://github.com/hubotio/evolution) process. 1. Check for [existing issues](https://github.com/github/hubot/issues) for duplicates and confirm that it hasn't been fixed already in the [main branch](https://github.com/github/hubot/commits/main) 1. Fork the repo, and clone it locally 1. `npm link` to make your cloned repo available to npm 1. Follow [Getting Started](docs/index.md) to generate a testbot 1. `npm link hubot` in your newly created bot to use your hubot fork 1. Create a new branch for your contribution 1. Add [tests](test/) (run with `npm test`) 1. Push to your fork and submit a pull request At this point you're waiting on us. We like to at least comment on, if not accept, pull requests within a few days. We may suggest some changes or improvements or alternatives. Some things that will increase the chance that your pull request is accepted: * Make sure the tests pass * Update the documentation: code comments, example code, guides. Basically, update everything affected by your contribution. * Include any information that would be relevant to reproducing bugs, use cases for new features, etc. * Discuss the impact on existing [hubot installs](docs/index.md), [hubot adapters](docs/adapters.md), and [hubot scripts](docs/scripting.md) (e.g. backwards compatibility) * If the change does break compatibility, how can it be updated to become backwards compatible, while directing users to the new way of doing things? * Your commits are associated with your GitHub user: https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/ * Make pull requests against a feature branch, * Follow our commit message conventions: * use imperative, present tense: “change” not “changed” nor “changes” * Commit test files with `test: …` or `test(scope): …` prefix. * Commit bug fixes with `fix: …` or `fix(scope): …` prefix * Commit features with `feat: …` or `feat(scope): …` prefix * Commit breaking changes by adding `BREAKING CHANGE:` in the commit body. The commit subject does not matter. A commit can have multiple `BREAKING CHANGE:` sections * Commit changes to `package.json`, `.gitignore` and other meta files with `chore(filenamewithout.ext): …` * Commit changes to README files or comments with `docs(README): …` * Cody style changes with `style: standard` * see [Angular’s Commit Message Conventions](https://gist.github.com/stephenparish/9941e89d80e2bc58a153) for a full list of recommendations. # Stale issue and pull request policy Issues and pull requests have a shelf life and sometimes they are no longer relevant. All issues and pull requests that have not had any activity for 90 days will be marked as `stale`. Simply leave a comment with information about why it may still be relevant to keep it open. If no activity occurs in the next 7 days, it will be automatically closed. The goal of this process is to keep the list of open issues and pull requests focused on work that is actionable and important for the maintainers and the community. # Pull Request Reviews & releasing Releasing `hubot` is fully automated using [semantic-release](https://github.com/semantic-release/semantic-release). Once merged into the `main` branch, `semantic-release` will automatically release a new version based on the commit messages of the pull request. For it to work correctly, make sure that the correct commit message conventions have been used. The ones relevant are * `fix: …` will bump the fix version, e.g. 1.2.3 → 1.2.4 * `feat: …` will bump the feature version, e.g. 1.2.3 → 1.3.0 * `BREAKING CHANGE: …` in the commit body will bump the breaking change version, e.g. 1.2.3 → 2.0.0 ================================================ FILE: LICENSE.md ================================================ Copyright (c) 2011-2024 GitHub Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![Pipeline Status](https://github.com/hubotio/hubot/actions/workflows/pipeline.yml/badge.svg) ![Build Status: MacOS](https://github.com/hubotio/hubot/actions/workflows/nodejs-macos.yml/badge.svg) ![Build Status: Ubuntu](https://github.com/hubotio/hubot/actions/workflows/nodejs-ubuntu.yml/badge.svg) ![Build Status: Window](https://github.com/hubotio/hubot/actions/workflows/nodejs-windows.yml/badge.svg) # Hubot **Note: v10.0.4 accidentally contains the removal of CoffeeScript; v10.0.5 puts it back in** **Note: v11 removes CoffeeScript and converts this codebase to ESM** Hubot is a framework to build chat bots, modeled after GitHub's Campfire bot of the same name, hubot. He's pretty cool. He's [extendable with scripts](https://hubotio.github.io/hubot/docs#scripts) and can work on [many different chat services](https://hubotio.github.io/hubot/adapters.html). This repository provides a library that's distributed by `npm` that you use for building your own bots. See the [documentation](https://hubotio.github.io/hubot/docs.html) for details on getting up and running with your very own robot friend. In most cases, you'll probably never have to hack on this repo directly if you are building your own bot. But if you do, check out [CONTRIBUTING.md](CONTRIBUTING.md) # Create your own Hubot instance This will create a directory called `myhubot` in the current working directory. ```sh npx hubot --create myhubot --adapter @hubot-friends/hubot-slack npx hubot --create myhubot --adapter @hubot-friends/hubot-discord npx hubot --create myhubot --adapter @hubot-friends/hubot-ms-teams npx hubot --create myhubot --adapter @hubot-friends/hubot-irc ``` Review `scripts/example.mjs`. Create more scripts in the `scripts` folder. ## Command bus (robot.commands) Hubot includes a deterministic command subsystem for slash-style commands. It is safe by default and does not interfere with legacy `hear` and `respond` listeners. ### Basic Command Registration ```mjs export default (robot) => { robot.commands.register({ id: 'tickets.create', description: 'Create a ticket', aliases: ['ticket new', 'new ticket'], args: { title: { type: 'string', required: true }, priority: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' } }, sideEffects: ['creates external ticket'], handler: async (ctx) => { return `Created ticket: ${ctx.args.title}` } }) } ``` Invoke with addressing the bot: - `@hubot tickets.create --title "VPN down" --priority high` - `@hubot tickets.create title:"VPN down" priority:high` Commands that declare side effects will require confirmation before execution. The user is asked to confirm. They do so like so: ```sh @hubot yes @hubot no @hubot cancel ``` Aliases are for discovery and search only. They do not execute commands or create proposals. They are intent utterances. ### Built-in Help Command Hubot automatically registers a `help` command that provides command discovery and documentation: ``` @hubot help # List all commands @hubot help tickets # Filter commands by prefix @hubot help search "create ticket" # Search by keyword, alias, description, or example ``` ### Search for Commands ```mjs const results = robot.commands.search('ticket new') // [{ id: 'tickets.create', score: 100, matchedOn: 'alias' }, ...] ``` ### Custom Type Resolvers Extend validation with custom argument types: ```mjs export default (robot) => { // Register custom type resolver robot.commands.registerTypeResolver('project_id', async (value, schema, context) => { if (!value.startsWith('PRJ-')) { throw new Error('must start with PRJ-') } return value.toUpperCase() }) // Use it in a command robot.commands.register({ id: 'projects.deploy', description: 'Deploy a project', args: { projectId: { type: 'project_id', required: true } }, handler: async (ctx) => { return `Deploying ${ctx.args.projectId}` } }) } ``` ### Configuration Options When creating a CommandBus instance, you can configure: - `prefix` - Command prefix (default: '') - `proposalTTL` - Timeout for pending confirmations in milliseconds (default: 300000 = 5 minutes) - `logPath` - Path to NDJSON event log file (default: `.data/commands-events.ndjson`) - `disableLogging` - Disable event logging to disk (default: true - logging is disabled by default) - `permissionProvider` - Custom permission checking handler (optional) ### Permissions Control who can execute commands using room-based and role-based permissions. #### Room-Based Permissions Restrict command execution to specific chat rooms: ```mjs robot.commands.register({ id: 'sensitive.action', description: 'Admin-only action', permissions: { rooms: ['#admin', '#ops'] // Only allowed in these rooms }, handler: async (ctx) => { return 'Action executed!' } }) ``` Users in other rooms get: `Permission denied: command not allowed in this room` #### Role-Based Permissions Restrict command execution to users with specific roles: ```mjs robot.commands.register({ id: 'deploy.production', description: 'Deploy to production', permissions: { roles: ['admin', 'devops'] // Only users with these roles }, handler: async (ctx) => { return 'Deploying...' } }) ``` To enable role checking, provide a `permissionProvider` when creating CommandBus: ```mjs const commandBus = new CommandBus(robot, { permissionProvider: { hasRole: async (user, requiredRoles, context) => { // Custom logic to check if user has any of the required roles const userRoles = await fetchUserRoles(user.id) return requiredRoles.some(role => userRoles.includes(role)) } } }) ``` Without a permission provider, role-based permissions are ignored (allow by default). Room-based permissions are always enforced. ## License See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT). # Hubot History [Say hello to Hubot](https://github.blog/2011-10-25-say-hello-to-hubot/) [Cartoon with Hubot](https://www.youtube.com/watch?v=vq2jYFZVMDA&t=129s) [The Most Important Startup's Hardest Worker Isn't a Person](https://www.wired.com/2015/10/the-most-important-startups-hardest-worker-isnt-a-person/) [The Story of Hubot](https://www.youtube.com/watch?v=Je4TjjtFDNU) [Hubot by Hubotics](https://www.theoldrobots.com/hubot.html) [Automating Inefficiencies](https://zachholman.com/2011/01/automating-inefficiencies/) [Getting Started with Hubot](https://www.youtube.com/watch?v=A7fh6RIzGrw) ================================================ FILE: bin/Hubot.mjs ================================================ 'use strict' import fs from 'node:fs' import { resolve as pathResolve } from 'node:path' import OptParse from '../src/OptParse.mjs' import Hubot from '../index.mjs' import create from '../src/GenHubot.mjs' const switches = [ ['-a', '--adapter HUBOT_ADAPTER', 'The Adapter to use, e.g. "Shell" (to load the default hubot Shell adapter)'], ['-f', '--file HUBOT_FILE', 'Path to adapter file, e.g. "./adapters/CustomAdapter.mjs"'], ['-c', '--create HUBOT_CREATE', 'Create a deployable hubot'], ['-d', '--disable-httpd HUBOT_HTTPD', 'Disable the HTTP server'], ['-h', '--help', 'Display the help information'], ['-l', '--alias HUBOT_ALIAS', "Enable replacing the robot's name with alias"], ['-n', '--name HUBOT_NAME', 'The name of the robot in chat'], ['-r', '--require PATH', 'Alternative scripts path'], ['-t', '--config-check', "Test hubot's config to make sure it won't fail at startup"], ['-v', '--version', 'Displays the version of hubot installed'], ['-e', '--execute', 'Runs the command as if it were a hubot command'] ] const options = { adapter: process.env.HUBOT_ADAPTER, alias: process.env.HUBOT_ALIAS || false, create: process.env.HUBOT_CREATE || false, enableHttpd: process.env.HUBOT_HTTPD !== 'false', scripts: process.env.HUBOT_SCRIPTS || [], name: process.env.HUBOT_NAME || 'Hubot', file: process.env.HUBOT_FILE, configCheck: false } const Parser = new OptParse(switches) Parser.banner = 'Usage: hubot [options]' Parser.on('adapter', (opt, value) => { options.adapter = value }) Parser.on('file', (opt, value) => { options.file = value }) Parser.on('create', function (opt, value) { options.path = value options.create = true }) Parser.on('disable-httpd', opt => { options.enableHttpd = false }) Parser.on('help', function (opt, value) { console.log(Parser.toString()) return process.exit(0) }) Parser.on('alias', function (opt, value) { if (!value) { value = '/' } options.alias = value }) Parser.on('name', (opt, value) => { options.name = value }) Parser.on('execute', (opt, value) => { options.execute = value }) Parser.on('require', (opt, value) => { options.scripts.push(value) }) Parser.on('config-check', opt => { options.configCheck = true }) Parser.on('version', (opt, value) => { options.version = true }) Parser.on(undefined, (opt, value) => { console.warn(`Unknown option: ${opt}`) }) Parser.parse(process.argv) if (options.create) { options.hubotInstallationPath = process.env.HUBOT_INSTALLATION_PATH ?? 'hubot' create(options.path, options) process.exit(0) } if (options.file) { options.adapter = options.file.split('/').pop().split('.')[0] } const robot = Hubot.loadBot(options.adapter, options.enableHttpd, options.name, options.alias) export default robot async function loadScripts () { await robot.load(pathResolve('.', 'scripts')) await robot.load(pathResolve('.', 'src', 'scripts')) await loadExternalScripts() const tasks = options.scripts.map((scriptPath) => { if (scriptPath[0] === '/') { return robot.load(scriptPath) } return robot.load(pathResolve('.', scriptPath)) }) await Promise.all(tasks) } async function loadExternalScripts () { const externalScripts = pathResolve('.', 'external-scripts.json') try { const data = await fs.promises.readFile(externalScripts) try { robot.loadExternalScripts(JSON.parse(data)) } catch (error) { console.error(`Error parsing JSON data from external-scripts.json: ${error}`) process.exit(1) } } catch (e) { robot.logger.info('No external-scripts.json found. Skipping.') } } (async () => { await robot.load(pathResolve('.', 'configuration')) await robot.loadAdapter(options.file) if (options.version) { console.log(robot.version) process.exit(0) } if (options.configCheck) { await loadScripts() console.log('OK') process.exit(0) } robot.adapter.once('connected', async () => { await loadScripts() if (options.execute) { await robot.receive(new Hubot.TextMessage(new Hubot.User('shell', { room: '#shell' }), `@${robot.name} ${options.execute.trim()}`)) robot.shutdown() } robot.emit('scripts have loaded', robot) }) await robot.run() })() ================================================ FILE: bin/e2e-test.sh ================================================ #!/bin/bash HUBOT_FOLDER=$(pwd) TEMP_ROOT=$(mktemp -d) echo "$ pushd $TEMP_ROOT" pushd $TEMP_ROOT trap "{ CODE=$?; popd; rm -rf $TEMP_ROOT; exit $CODE; }" EXIT ## https://github.com/hubotio/hubot/blob/main/docs/index.md ## use this hubot version echo "Creating hubot in $TEMP_ROOT" echo " and installing Hubot from $HUBOT_FOLDER" npm init -y npm i $HUBOT_FOLDER export HUBOT_INSTALLATION_PATH=$HUBOT_FOLDER ./node_modules/.bin/hubot --create . # npm install /path/to/hubot will create a symlink in npm 5+ (http://blog.npmjs.org/post/161081169345/v500). # As the require calls for app-specific scripts happen inside hubot, we have to # set NODE_PATH to the app’s node_modules path so they can be found echo "$ Update NODE_PATH=$TEMP_ROOT/node_modules so everything can be found correctly." export NODE_PATH=$TEMP_ROOT/node_modules export PATH=$PATH:$TEMP_ROOT/node_modules/.bin ## start, but have to sleep 1 second to wait for hubot to start and the scripts to load expect < " sleep 1 send "e2etest PING\r" expect { "PONG" {} timeout {exit 1} } send "e2etest adapter\r" expect { "Shell" {} "shell" {} timeout {exit 1} } EOL ================================================ FILE: bin/hubot ================================================ #!/usr/bin/env node import('./Hubot.mjs').then(async ({ default: robot }) => {}) ================================================ FILE: configuration/Config.mjs ================================================ // Description: // Configuration // // Commands: // // Notes: // This is a test script. // export default async robot => { robot.config = {} } ================================================ FILE: docs/adapters/campfire.md ================================================ --- title: Campfire adapter layout: layouts/docs.html permalink: /adapters/campfire.html --- # Campfire adapter [Campfire](http://campfirenow.com/) is a web based chat application built by [37signals](http://37signals.com). The Campfire adapter is one of the original adapters in Hubot. ## Getting Started You will need a Campfire account to start. Next, you will need to create a user on your Campfire account for your Hubot, then give it access so it can join to your rooms. You will need to create a room if you haven't already. Hubot defaults to using its [Shell](./shell.html), so to use Campfire instead, you can run hubot with `-a Campfire`: % bin/hubot -a campfire If you are using foreman, you need to make sure the hubot is called with `-a Campfire` in the `Procfile`: web: bin/hubot -a campfire -n Hubot ## Configuring The adapter requires the following environment variables. * `HUBOT_CAMPFIRE_ACCOUNT` * `HUBOT_CAMPFIRE_TOKEN` * `HUBOT_CAMPFIRE_ROOMS` ### Campfire API Token This can be found by logging in with your hubot's account click the **My Info** link and make a note of the API token. ### Campfire Room IDs If you join the rooms you want your hubot to join will see notice a numerical ID for the room in the URL. Make a note of each ID for the rooms you want your hubot to join. ### Campfire Account This is simply the first part of the domain you visit for your Campfire account. For example if your Campfire was at `hubot.campfirenow.com` your subdomain is `hubot`. Make a note of the subdomain. ### Configuring the variables on UNIX % export HUBOT_CAMPFIRE_TOKEN="..." % export HUBOT_CAMPFIRE_ROOMS="123,321" % export HUBOT_CAMPFIRE_ACCOUNT="..." ### Configuring the variables on Windows Using PowerShell: setx HUBOT_CAMPFIRE_TOKEN "..." /m setx HUBOT_CAMPFIRE_ROOMS "123,321" /m setx HUBOT_CAMPFIRE_ACCOUNT "..." /m ================================================ FILE: docs/adapters/development.md ================================================ --- title: Development adapter layout: layouts/docs.html permalink: /adapters/development.html --- # Development adapter ## Adapter Basics All adapters inherit from the Adapter class in the `src/Adapter.mjs` file. ```javascript const Adapter = require('hubot/index.mjs').Adapter; ``` There are certain methods that you will want to override. Here is a basic stub of what an extended Adapter class would look like: ```javascript const Adapter = require('../adapter') const User = require('../user') const TextMessage = require('../message').TextMessage class Sample extends Adapter { constructor(robot) { super(robot) this.robot.logger.info('Constructor') } send(envelope, ...strings) { this.robot.logger.info('Send') } reply(envelope, ...strings) { this.robot.logger.info('Reply') } run() { this.robot.logger.info('Run') this.emit('connected') // The 'connected' event is required to trigger loading of Hubot scripts. const user = new User(1001, 'Sample User') const message = new TextMessage(user, 'Some Sample Message', 'MSG-001') this.robot.receive(message) } } exports.use = (robot) => new Sample(robot) ``` ## Option 1. Setting Up Your Development Environment 1. Create a new folder for your adapter `hubot-sample` - `mkdir hubot-sample` 2. Change your working directory to `hubot-sample` - `cd hubot-sample` 3. Run `npm init` to create your package.json - make sure the entry point is `src/sample.js` 4. Add your `.gitignore` to include `node_modules` 5. Edit the `src/sample.js` file to include the above stub for your adapter 6. Edit the `package.json` to add a peer dependency on `hubot` ```json "dependencies": { }, "peerDependencies": { "hubot": ">=11" } ``` 7. Generate your Hubot using the `npx hubot --create myhubot` 8. Change working directories to the `hubot` you created in step 7. 9. Now perform an `npm link` to add your adapter to `hubot` - `npm link ../hubot-sample` 10. Run `hubot -a sample` ## Gotchas There is a an open issue in the node community around [npm linked peer dependencies not working](https://github.com/npm/npm/issues/5875). To get this working for our project you will need to do some minor changes to your code. 1. For the import in your `hubot-sample` adapter, add the following code ```javascript let {Robot,Adapter,TextMessage,User} = {} try { {Robot,Adapter,TextMessage,User} = require('hubot') } catch { const prequire = require('parent-require') {Robot,Adapter,TextMessage,User} = prequire('hubot') } ``` 2. In your `hubot-sample` folder, modify the `package.json` to include the following dependency so this custom import mechanism will work ```json "dependencies": { "parent-require": "^1.0.0" } ``` 3. Now try running `hubot -a sample` again and see that the imports are properly loaded. 4. Once this is working properly, you can build out the functionality of your adapter as you see fit. Take a look at some of the other adapters to get some ideas for your implementation. - Once packaged and deployed via `npm`, you won't need the dependency in `hubot` anymore since the peer dependency should work as an official module. ## Option 2. Setting Up Your Development Environment Another option is to load the file from local disk. 1. Create a new folder for your adapter `hubot-sample` - `mkdir hubot-sample` 2. Change your working directory to `hubot-sample` - `cd hubot-sample` 3. Run `npm init` to create your package.json - make sure the entry point is `src/sample.js` 4. Add your `.gitignore` to include `node_modules` 5. Edit the `src/sample.js` file to include the above stub for your adapter 6. Edit the `package.json` to add a peer dependency on `hubot` ```json "dependencies": { }, "peerDependencies": { "hubot": ">=11" } ``` 7. Run `npx hubot -p ./src -a sample.js` ================================================ FILE: docs/adapters/shell.md ================================================ --- title: Shell adapter layout: layouts/docs.html permalink: /adapters/shell.html --- # Shell adapter The shell adapter provides a simple read-eval-print loop for interacting with a hubot locally. It can be useful for testing scripts before using them on a live hubot. ## Getting Started To use the Shell adapter you can simply omit the `-a` option when running hubot as it will use the Shell adapter by default. % bin/hubot ## Configuring This adapter doesn't require any configuration. It supports two environment variables to make it possible to test scripts as different users: * HUBOT_SHELL_USER_ID: default is 1 * HUBOT_SHELL_USER_NAME: default is Shell ================================================ FILE: docs/adapters.md ================================================ --- title: Adapters layout: layouts/docs.html permalink: /adapters.html --- # Adapters Adapters are the interface to the service you want your hubot to run on. Hubot includes two official adapters: * [Shell](./adapters/shell.html), i.e. for use with development * [Campfire](./adapters/campfire.html) There are Third-party adapters available for most chat services. Here are the most popular ones: * [Discord](https://github.com/hubot-friends/hubot-discord) * [IRC](https://github.com/hubot-friends/hubot-irc) * [Slack](https://github.com/hubot-friends/hubot-slack) * [MS Teams](https://github.com/hubot-friends/hubot-ms-teams) * [Gitter](https://github.com/huafu/hubot-gitter2) * [HipChat](https://github.com/hipchat/hubot-hipchat) * [Rocket.Chat](https://github.com/RocketChat/hubot-rocketchat) * [XMPP](https://github.com/markstory/hubot-xmpp) Browse all [repositories with the `hubot-adapter` topic on GitHub](https://github.com/search?q=topic%3Ahubot-adapter&type=Repositories) or [search for adapters on NPM](https://www.npmjs.com/search?q=hubot%20adapter&ranking=popularity). Add the `hubot-adapter` [topic](https://help.github.com/articles/classifying-your-repository-with-topics/) to your repository on GitHub to include it in this list. ## Writing Your Own Adapter Interested in adding your own adapter? Check out our documentation for [developing adapters](./adapters/development.html) ================================================ FILE: docs/assets/stylesheets/application.css ================================================ @font-face { font-family: 'octicons'; src: url("../vendors/octicons/octicons/octicons.eot"); src: url("../vendors/octicons/octicons/octicons.eot?#iefix") format("embedded-opentype"), url("../vendors/octicons/octicons/octicons.woff") format("woff"), url("../vendors/octicons/octicons/octicons.ttf") format("truetype"), url("../vendors/octicons/octicons/octicons.svg#svgFontName") format("svg"); } * { box-sizing: border-box; } article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; } audio, canvas, video { display: inline-block; } audio:not([controls]) { display: none; } [hidden] { display: none; } html { font-size: 21px; } body { margin: 0; font-size: 0.8rem; line-height: 1.5; } body, button, input, select, textarea { color: #222; } ::-moz-selection { background: #4793bd; color: #fff; text-shadow: none; } ::selection { background: #4793bd; color: #fff; text-shadow: none; } a { color: #00e; } a:visited { color: #551a8b; } a:hover { color: #06e; } a:focus { outline: thin dotted; } a:hover, a:active { outline: 0; } abbr[title] { border-bottom: 1px dotted; } b, strong { font-weight: bold; } blockquote { margin: 1em 40px; } dfn { font-style: italic; } hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; } ins { background: #ff9; color: #000; text-decoration: none; } mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; } code { white-space: pre-wrap; word-break: break-word; } pre, code, kbd, samp { font-family: monospace, monospace; font-size: 1em; } q { quotes: none; } q:before, q:after { content: ""; content: none; } small { font-size: 0.5rem; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sup { top: -0.5em; } sub { bottom: -0.25em; } ul, ol { margin: 1em 0; padding: 0 0 0 40px; } dd { margin: 0 0 0 40px; } nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; } img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; } svg:not(:root) { overflow: hidden; } figure { margin: 0; } form { margin: 0; } fieldset { border: 0; margin: 0; padding: 0; } label { cursor: pointer; } legend { border: 0; padding: 0; } button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; } button, input { line-height: normal; } button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; appearance: button; } input[type="checkbox"], input[type="radio"] { box-sizing: border-box; } input[type="search"] { appearance: textfield; box-sizing: content-box; } input[type="search"]::-webkit-search-decoration { appearance: none; } button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } textarea { overflow: auto; vertical-align: top; resize: vertical; } input:invalid, textarea:invalid { background-color: #f0dddd; } table { border-collapse: collapse; border-spacing: 0; } td { vertical-align: top; } @font-face { font-family: HandOfSeanRegular; src: url("../fonts/eot/handsean-webfont.eot"); src: local("☺"), url("../fonts/otf/handsean-webfont.ttf") format("truetype"), url("../fonts/svg/handsean-webfont.svg#webfontV6q8jOTr") format("svg"), url("../fonts/woff/handsean-webfont.woff") format("woff"); font-weight: normal; font-style: normal; } @font-face { font-family: PoliticaThin; src: url("../fonts/eot/style_154053.eot"); src: local("☺"), url("../fonts/otf/style_154053.otf") format("opentype"), url("../fonts/svg/style_154053.svg#PoliticaThin") format("svg"), url("../fonts/woff/style_154053.woff") format("woff"); } @font-face { font-family: PoliticaThin-Italic; src: url("../fonts/eot/style_154049.eot"); src: local("☺"), url("../fonts/svg/style_154049.svg#PoliticaThin-Italic") format("svg"), url("../fonts/woff/style_154049.woff") format("woff"); } @font-face { font-family: PoliticaLight; src: url("../fonts/eot/style_154051.eot"); src: local("☺"), url("../fonts/otf/style_154051.otf") format("opentype"), url("../fonts/svg/style_154051.svg#PoliticaLight") format("svg"), url("../fonts/woff/style_154051.woff") format("woff"); } @font-face { font-family: PoliticaLight-Italic; src: url("../fonts/eot/style_154044.eot"); src: local("☺"), url("../fonts/svg/style_154044.svg#PoliticaLight-Italic") format("svg"), url("../fonts/woff/style_154044.woff") format("woff"); } @font-face { font-family: Politica; src: url("../fonts/eot/style_154046.eot"); src: local("☺"), url("../fonts/otf/style_154046.otf") format("opentype"), url("../fonts/svg/style_154046.svg#Politica") format("svg"), url("../fonts/woff/style_154046.woff") format("woff"); } @font-face { font-family: Politica-Italic; src: url("../fonts/eot/style_154048"); src: local("☺"), url("../fonts/otf/style_154048.otf") format("opentype"), url("../fonts/svg/style_154048.svg#Politica-Italic") format("svg"), url("../fonts/woff/style_154048.woff") format("woff"); } @font-face { font-family: Politica-Bold; src: url("../fonts/eot/style_154045.eot"); src: local("☺"), url("../fonts/otf/style_154045.otf") format("opentype"), url("../fonts/svg/style_154045.svg#Politica-Bold") format("svg"), url("../fonts/woff/style_154045.woff") format("woff"); } @font-face { font-family: Politica-BoldItalic; src: url("../fonts/eot/style_154042.eot"); src: local("☺"), url("../fonts/otf/style_154042.otf") format("opentype"), url("../fonts/svg/style_154042.svg#Politica-BoldItalic") format("svg"), url("../fonts/woff/style_154042.woff") format("woff"); } @font-face { font-family: 'Octicons Regular'; src: url("../fonts/eot/octicons-regular-webfont.eot"); src: local("☺"), url("../fonts/eot/octicons-regular-webfont.eot#iefix") format("embedded-opentype"), url("../fonts/otf/octicons-regular-webfont.otf") format("opentype"), url("../fonts/svg/octicons-regular-webfont.svg#newFontRegular") format("svg"), url("../fonts/woff/octicons-regular-webfont.woff") format("woff"); } a, a:visited { color: #5f8faf; text-decoration: none; } body { background: url("../images/layout/project-paper.png"); font-family: 'Helvetica Neue', Helvetica, arial, freesans, clean, sans-serif; padding: 1rem; } .container { background: #fff; margin: 0 auto; box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.05), 0 0 0 12px rgba(0, 0, 0, 0.02) inset; } .frame { background: url("../images/layout/header-field-emblem.png") no-repeat left top; border: solid 1px #eee; } .hubot-avatar { background-color: #fff; background-image: linear-gradient(#fff 10%, #f7f7f7 90%); background-position: center center; background-repeat: no-repeat; border-radius: 4px; height: 106px; left: 1px; top: 1px; width: 106px; transform: rotate(-3deg); box-shadow: 0 0 1px transparent, 0 2px 2px rgba(0, 0, 0, 0.2); } .hubot-avatar:before { background: url("../images/layout/tape.png") no-repeat left top; content: ''; display: block; height: 26px; position: absolute; top: -12px; left: 4px; width: 99px; z-index: 1; } .hubot-avatar .hubot-avatar-img { width: 100%; position: absolute; top: 0; left: 0; right: 0; margin: auto; } header.clearfix { position: relative; display: flex; flex-wrap: wrap; justify-content: space-evenly; gap: 1rem; } header.clearfix h1 { background: url("../images/layout/header-field-1.png") no-repeat left bottom; color: #333; font-family: Politica; font-size: 72px; margin: 0 12px; position: relative; text-transform: uppercase; width: 252px; } header.clearfix h1 span { color: #7880a7; display: block; font-family: 'HandOfSeanRegular'; font-size: 14px; font-weight: normal; left: 156px; letter-spacing: -1px; position: absolute; top: 32px; text-transform: lowercase; width: 125px; transform: rotate(-3deg); } header.clearfix h1 span b { font-size: 18px; font-weight: normal; } header.clearfix h2 { background: url("../images/layout/header-field-2.png") no-repeat left bottom; color: #7880a7; float: left; font-size: 16px; font-weight: normal; height: 132px; line-height: 18px; margin: 0 12px; padding-top: 52px; text-transform: uppercase; width: 252px; } header.clearfix p { background: url("../images/layout/header-field-3.png") no-repeat left bottom; color: #bebebe; float: left; font-size: 16px; font-weight: normal; height: 132px; line-height: 18px; margin: 0 0 0 12px; padding-top: 34px; text-transform: uppercase; width: 252px; } .download { line-height: 24px; } .insides { margin: 12px 0 12px; display: flex; flex-wrap: wrap; justify-content: space-evenly; gap: 1rem; } .insides .button { background: url("../images/layout/checkbawx.png") no-repeat 12px 12px; border: 1px solid #ddd; margin: 0; padding: 10px 12px 10px 30px; text-transform: uppercase; display: block; width: 40%; } @media (max-width: 920px) { .insides .button { width: 80%; } } .insides .button:hover { background-position: 12px -44px; border: 1px solid #ccc; background-color: rgba(254, 174, 40, 0.1); } .insides .button:first-child { margin-left: 0; } .insides span { color: #ccc; display: block; font-size: 10px; } .main { position: relative; } .schematics { background: url("../images/layout/schematic-shadow.png") no-repeat center bottom; height: 444px; z-index: 5; transform: rotate(-1deg); } .schematic { background: url("../images/layout/old-mathematics.png"); background-size: auto, 100px; height: 432px; position: relative; box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.2); } .schematic .schematic-img { margin: 10px auto 0; display: block; padding-top: 25px; width: 100%; height: 100%; } .schematic p { background: #fff; border: 1px solid rgba(0, 0, 0, 0.1); color: #999; bottom: 12px; font-size: 10px; left: 24px; padding: 6px 12px; position: absolute; text-transform: uppercase; width: 172px; } .about { color: #666; line-height: 24px; padding: 1rem 5rem; width: 100%; display: flex; flex-direction: row; gap: 1rem; justify-content: space-evenly; align-items: center; } .about article { width: 70%; } .about aside { width: 30%; } @media (max-width: 920px) { .about { flex-direction: column; padding: 1rem 3rem; } .about aside, .about article { width: 100%; } } .about h2 { border-bottom: 1px solid #eee; padding-bottom: 12px; color: #222; font-weight: normal; margin-top: 24px; } .letter { background: url("../images/layout/emblem.png") no-repeat 92% 92%, url("../images/layout/signature.png") no-repeat 42px 98%, url("../images/layout/lined-paper.png") no-repeat left top; line-height: 24px; padding: 48px 24px 106px 48px; box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.2); position: relative; margin-bottom: 2rem; } .letter:before, .letter:after { background: url("../images/layout/tape.png") no-repeat left top; content: ''; position: absolute; width: 99px; height: 26px; } .letter:before { top: -12px; } .letter:after { top: -12px; right: 32px; } .screenshot { width: 100%; text-align: center; z-index: 60; position: relative; } .screenshot img { width: 100%; box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.25); } .screenshot:before { background: url("../images/layout/tape.png") no-repeat left top; content: ''; display: block; height: 26px; position: absolute; top: -12px; left: 50%; margin-left: -49px; width: 99px; } .footer { color: #4c82a5; margin: 24px auto 0; padding: 0 24px; } .footer a:visited { color: #5f8faf; } .footer .right { margin: 0; float: right; } .mega-icon { font-family: 'Octicons Regular'; font-weight: normal; font-style: normal; display: inline-block; line-height: 1; -webkit-font-smoothing: antialiased; text-decoration: none; } .mega-icon-invertocat { color: #4c82a5; position: absolute; left: 50%; height: 24px; width: 24px; margin-top: -4px; margin-left: -12px; font-size: 24px; } .mega-icon-invertocat:before { content: "\f20a"; } .docs .container .main { display: flex; padding: 0 2rem; } .docs .container .main h2 { border-bottom: 1px solid #eee; padding-bottom: 0.8rem; color: #222; font-weight: normal; } .docs .container header.clearfix { display: block; margin-left: 170px; position: relative; } .docs .container header.clearfix a { position: absolute; top: 0; left: -135px; } .docs header h1 { background: none; width: auto; border-bottom: 1px solid #ccc; margin-bottom: 30px; } .docs-nav { margin-right: 20px; margin-top: 20px; border: 1px solid #ccc; font-size: 14px; } .docs-nav li { border-top: 1px solid #ccc; } .docs-nav .subpage .docs-link { padding-left: 24px; } .docs-nav .docs-list>li:first-child { border-top: none; } .docs-nav .docs-link { display: block; padding: 8px 10px; } .docs-nav .docs-link:hover, .docs-nav .docs-link.current { background-color: rgba(254, 174, 40, 0.1); color: #333; border-right: 4px solid #feae28; padding-right: 6px; } @media (max-width: 600px) { .mega-icon-invertocat { position: static; } .docs-nav { width: 100%; margin: 0; } .docs .container .main { display: flex; flex-direction: column; padding: 0 1rem; } .docs header h1 { font-size: 2.5rem; } .docs .container header.clearfix { margin-left: 0; } .docs .container header.clearfix a { position: relative; top: 0; left: 0; } } @font-face { font-family: 'octicons'; src: font-url("octicons.eot?#iefix") format("embedded-opentype"), font-url("octicons.woff") format("woff"), font-url("octicons.ttf") format("truetype"), font-url("octicons.svg#octicons") format("svg"); font-weight: normal; font-style: normal; } .octicon, .mega-octicon { font: normal normal normal 16px/1 octicons; display: inline-block; text-decoration: none; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; user-select: none; } .mega-octicon { font-size: 32px; } .octicon-alert:before { content: '\f02d'; } .octicon-alignment-align:before { content: '\f08a'; } .octicon-alignment-aligned-to:before { content: '\f08e'; } .octicon-alignment-unalign:before { content: '\f08b'; } .octicon-arrow-down:before { content: '\f03f'; } .octicon-arrow-left:before { content: '\f040'; } .octicon-arrow-right:before { content: '\f03e'; } .octicon-arrow-small-down:before { content: '\f0a0'; } .octicon-arrow-small-left:before { content: '\f0a1'; } .octicon-arrow-small-right:before { content: '\f071'; } .octicon-arrow-small-up:before { content: '\f09f'; } .octicon-arrow-up:before { content: '\f03d'; } .octicon-beer:before { content: '\f069'; } .octicon-book:before { content: '\f007'; } .octicon-bookmark:before { content: '\f07b'; } .octicon-briefcase:before { content: '\f0d3'; } .octicon-broadcast:before { content: '\f048'; } .octicon-browser:before { content: '\f0c5'; } .octicon-bug:before { content: '\f091'; } .octicon-calendar:before { content: '\f068'; } .octicon-check:before { content: '\f03a'; } .octicon-checklist:before { content: '\f076'; } .octicon-chevron-down:before { content: '\f0a3'; } .octicon-chevron-left:before { content: '\f0a4'; } .octicon-chevron-right:before { content: '\f078'; } .octicon-chevron-up:before { content: '\f0a2'; } .octicon-circle-slash:before { content: '\f084'; } .octicon-circuit-board:before { content: '\f0d6'; } .octicon-clippy:before { content: '\f035'; } .octicon-clock:before { content: '\f046'; } .octicon-cloud-download:before { content: '\f00b'; } .octicon-cloud-upload:before { content: '\f00c'; } .octicon-code:before { content: '\f05f'; } .octicon-color-mode:before { content: '\f065'; } .octicon-comment-add:before, .octicon-comment:before { content: '\f02b'; } .octicon-comment-discussion:before { content: '\f04f'; } .octicon-credit-card:before { content: '\f045'; } .octicon-dash:before { content: '\f0ca'; } .octicon-dashboard:before { content: '\f07d'; } .octicon-database:before { content: '\f096'; } .octicon-device-camera:before { content: '\f056'; } .octicon-device-camera-video:before { content: '\f057'; } .octicon-device-desktop:before { content: '\f27c'; } .octicon-device-mobile:before { content: '\f038'; } .octicon-diff:before { content: '\f04d'; } .octicon-diff-added:before { content: '\f06b'; } .octicon-diff-ignored:before { content: '\f099'; } .octicon-diff-modified:before { content: '\f06d'; } .octicon-diff-removed:before { content: '\f06c'; } .octicon-diff-renamed:before { content: '\f06e'; } .octicon-ellipsis:before { content: '\f09a'; } .octicon-eye-unwatch:before, .octicon-eye-watch:before, .octicon-eye:before { content: '\f04e'; } .octicon-file-binary:before { content: '\f094'; } .octicon-file-code:before { content: '\f010'; } .octicon-file-directory:before { content: '\f016'; } .octicon-file-media:before { content: '\f012'; } .octicon-file-pdf:before { content: '\f014'; } .octicon-file-submodule:before { content: '\f017'; } .octicon-file-symlink-directory:before { content: '\f0b1'; } .octicon-file-symlink-file:before { content: '\f0b0'; } .octicon-file-text:before { content: '\f011'; } .octicon-file-zip:before { content: '\f013'; } .octicon-flame:before { content: '\f0d2'; } .octicon-fold:before { content: '\f0cc'; } .octicon-gear:before { content: '\f02f'; } .octicon-gift:before { content: '\f042'; } .octicon-gist:before { content: '\f00e'; } .octicon-gist-secret:before { content: '\f08c'; } .octicon-git-branch-create:before, .octicon-git-branch-delete:before, .octicon-git-branch:before { content: '\f020'; } .octicon-git-commit:before { content: '\f01f'; } .octicon-git-compare:before { content: '\f0ac'; } .octicon-git-merge:before { content: '\f023'; } .octicon-git-pull-request-abandoned:before, .octicon-git-pull-request:before { content: '\f009'; } .octicon-globe:before { content: '\f0b6'; } .octicon-graph:before { content: '\f043'; } .octicon-heart:before { content: '\2665'; } .octicon-history:before { content: '\f07e'; } .octicon-home:before { content: '\f08d'; } .octicon-horizontal-rule:before { content: '\f070'; } .octicon-hourglass:before { content: '\f09e'; } .octicon-hubot:before { content: '\f09d'; } .octicon-inbox:before { content: '\f0cf'; } .octicon-info:before { content: '\f059'; } .octicon-issue-closed:before { content: '\f028'; } .octicon-issue-opened:before { content: '\f026'; } .octicon-issue-reopened:before { content: '\f027'; } .octicon-jersey:before { content: '\f019'; } .octicon-jump-down:before { content: '\f072'; } .octicon-jump-left:before { content: '\f0a5'; } .octicon-jump-right:before { content: '\f0a6'; } .octicon-jump-up:before { content: '\f073'; } .octicon-key:before { content: '\f049'; } .octicon-keyboard:before { content: '\f00d'; } .octicon-law:before { content: '\f0d8'; } .octicon-light-bulb:before { content: '\f000'; } .octicon-link:before { content: '\f05c'; } .octicon-link-external:before { content: '\f07f'; } .octicon-list-ordered:before { content: '\f062'; } .octicon-list-unordered:before { content: '\f061'; } .octicon-location:before { content: '\f060'; } .octicon-gist-private:before, .octicon-mirror-private:before, .octicon-git-fork-private:before, .octicon-lock:before { content: '\f06a'; } .octicon-logo-github:before { content: '\f092'; } .octicon-mail:before { content: '\f03b'; } .octicon-mail-read:before { content: '\f03c'; } .octicon-mail-reply:before { content: '\f051'; } .octicon-mark-github:before { content: '\f00a'; } .octicon-markdown:before { content: '\f0c9'; } .octicon-megaphone:before { content: '\f077'; } .octicon-mention:before { content: '\f0be'; } .octicon-microscope:before { content: '\f089'; } .octicon-milestone:before { content: '\f075'; } .octicon-mirror-public:before, .octicon-mirror:before { content: '\f024'; } .octicon-mortar-board:before { content: '\f0d7'; } .octicon-move-down:before { content: '\f0a8'; } .octicon-move-left:before { content: '\f074'; } .octicon-move-right:before { content: '\f0a9'; } .octicon-move-up:before { content: '\f0a7'; } .octicon-mute:before { content: '\f080'; } .octicon-no-newline:before { content: '\f09c'; } .octicon-octoface:before { content: '\f008'; } .octicon-organization:before { content: '\f037'; } .octicon-package:before { content: '\f0c4'; } .octicon-paintcan:before { content: '\f0d1'; } .octicon-pencil:before { content: '\f058'; } .octicon-person-add:before, .octicon-person-follow:before, .octicon-person:before { content: '\f018'; } .octicon-pin:before { content: '\f041'; } .octicon-playback-fast-forward:before { content: '\f0bd'; } .octicon-playback-pause:before { content: '\f0bb'; } .octicon-playback-play:before { content: '\f0bf'; } .octicon-playback-rewind:before { content: '\f0bc'; } .octicon-plug:before { content: '\f0d4'; } .octicon-repo-create:before, .octicon-gist-new:before, .octicon-file-directory-create:before, .octicon-file-add:before, .octicon-plus:before { content: '\f05d'; } .octicon-podium:before { content: '\f0af'; } .octicon-primitive-dot:before { content: '\f052'; } .octicon-primitive-square:before { content: '\f053'; } .octicon-pulse:before { content: '\f085'; } .octicon-puzzle:before { content: '\f0c0'; } .octicon-question:before { content: '\f02c'; } .octicon-quote:before { content: '\f063'; } .octicon-radio-tower:before { content: '\f030'; } .octicon-repo-delete:before, .octicon-repo:before { content: '\f001'; } .octicon-repo-clone:before { content: '\f04c'; } .octicon-repo-force-push:before { content: '\f04a'; } .octicon-gist-fork:before, .octicon-repo-forked:before { content: '\f002'; } .octicon-repo-pull:before { content: '\f006'; } .octicon-repo-push:before { content: '\f005'; } .octicon-rocket:before { content: '\f033'; } .octicon-rss:before { content: '\f034'; } .octicon-ruby:before { content: '\f047'; } .octicon-screen-full:before { content: '\f066'; } .octicon-screen-normal:before { content: '\f067'; } .octicon-search-save:before, .octicon-search:before { content: '\f02e'; } .octicon-server:before { content: '\f097'; } .octicon-settings:before { content: '\f07c'; } .octicon-log-in:before, .octicon-sign-in:before { content: '\f036'; } .octicon-log-out:before, .octicon-sign-out:before { content: '\f032'; } .octicon-split:before { content: '\f0c6'; } .octicon-squirrel:before { content: '\f0b2'; } .octicon-star-add:before, .octicon-star-delete:before, .octicon-star:before { content: '\f02a'; } .octicon-steps:before { content: '\f0c7'; } .octicon-stop:before { content: '\f08f'; } .octicon-repo-sync:before, .octicon-sync:before { content: '\f087'; } .octicon-tag-remove:before, .octicon-tag-add:before, .octicon-tag:before { content: '\f015'; } .octicon-telescope:before { content: '\f088'; } .octicon-terminal:before { content: '\f0c8'; } .octicon-three-bars:before { content: '\f05e'; } .octicon-tools:before { content: '\f031'; } .octicon-trashcan:before { content: '\f0d0'; } .octicon-triangle-down:before { content: '\f05b'; } .octicon-triangle-left:before { content: '\f044'; } .octicon-triangle-right:before { content: '\f05a'; } .octicon-triangle-up:before { content: '\f0aa'; } .octicon-unfold:before { content: '\f039'; } .octicon-unmute:before { content: '\f0ba'; } .octicon-versions:before { content: '\f064'; } .octicon-remove-close:before, .octicon-x:before { content: '\f081'; } .octicon-zap:before { content: '\26A1'; } ================================================ FILE: docs/deploying/azure.md ================================================ --- title: Deploying to Azure layout: layouts/docs.html permalink: /deploying/azure.html --- # Deploying to Azure If you've been following along with [Getting Started](../index.html), it's time to deploy so you can use it beyond just your local machine. [Azure](http://azure.microsoft.com/) is a way to deploy hubot. You will need to install the azure-cli via npm after you have follow the initial instructions for your hubot. % npm install -g azure-cli Inside your new hubot directory, make sure you've created a git repository, and that your work is committed: % git init % git add . % git commit -m "Initial commit" Then [create a GitHub repository](https://help.github.com/articles/create-a-repo/) for your hubot. This is where Azure will pull your code from instead of needing to deploy directly from your dev machine to Azure. % git remote add origin _your GitHub repo_ % git push -u origin main Once you have your GitHub repo, create an Azure website linked to your repo. In Azure, create a website and select integrated source control. When it asks "where is your source control" select GitHub and link this website to your git repo that you created in the previous step. If you have downloaded the Azure PowerShell modules, you can also do this via PowerShell. % $creds = Get-Credential % New-AzureWebsite mynewhubot -github -githubrepository yourgithubaccount/yourhubotreponame -githubcredentials $creds Once you have done this, Azure will deploy your site any time you commit and push to GitHub. Your hubot won't run quite right yet, though. Next, you need to configure the deployment to tell Azure how to run hubot. First, run the follow command to add `deploy.cmd` to your hubot directory. This is the file that Azure uses to know how to deploy your node application. % azure site deploymentscript --node Then, edit this file and look for the sections that give you steps 1, 2 and 3. You're going to add a 4th step: :: 4. Create Hubot file with a js extension copy /Y "%DEPLOYMENT_TARGET%\node_modules\hubot\bin\hubot" "%DEPLOYMENT_TARGET%\node_modules\hubot\bin\Hubot.mjs" Now, create a new file in the base directory of hubot called `server.js` and put these two lines into it: module.exports = await import('hubot/bin/Hubot.mjs'); Finally you will need to add the environment variables to the website to make sure it runs properly. You can either do it through the GUI (under configuration) or you can use the Azure PowerShell command line, as follows (example is showing slack as an adapter and mynewhubot as the website name). % $settings = New-Object Hashtable % $settings["HUBOT_ADAPTER"] = "Slack" % $settings["HUBOT_SLACK_TOKEN"] = "yourslackapikey" % Set-AzureWebsite -AppSettings $settings mynewhubot Commit your changes in git and push to GitHub and Azure will automatically pick up the changes and deploy them to your website. % git commit -m "Add Azure settings for hubot" % git push Azure offers a marketplace where you can use the default hubot-redis-brain using Redis Cloud provided by Redis Labs. Alternatively, to add an [Azure blob storage brain](https://github.com/coryallegory/hubot-azure-brain), you will need to create an Azure storage account. Then you can do the following in your base hubot directory. % npm install hubot-azure-brain --save Then add the following line in `external-scripts.json` in the list with the other external scripts "hubot-azure-brain" Finally, add one more environment variables to your website. You can do this either via the GUI or the following PowerShell commands. % $settings = New-Object Hashtable % $settings["HUBOT_BRAIN_AZURE_CONNSTRING"] = "your Azure blob storage connection string" % Set-AzureWebsite -AppSettings $settings mynewhubot Now any scripts that require a brain will function. You should look up other scripts or write your own by looking at the [documentation](../scripting.html). All of the normal scripts for hubot are compatible with hosting hubot on Azure. ### Troubleshooting tips and tricks Due to Azure being Windows-based, you may run into path length problems. To overcome this issue you can set the environment variable `IN_PLACE_DEPLOYMENT` to `1` and use [custom deployment scripts to take advantage of NPM3](https://github.com/felixrieseberg/azure-npm3) and flat module installation. If you have your instance of hubot deploying directly from source control to Azure, it will perform various bootstrapping tasks along with calling `npm install` via kudu. Now that Azure supports npm5, this process generates a package-lock.json file which may result in some of your packages failing to install remotely. You can edit `deploy.cmd` to include `--no-package-lock` as part of the "Step 3" install command. If using the free tier of Azure, you can also add a post-deployment step to ping the server on startup by setting the environment variable `POST_DEPLOYMENT_ACTION` with a script (relative to the src dir) such as `startup.sh` An example of a startup script: ``` let retrys=0 while : ; do STATUSCODE=$(curl --silent --output /dev/stderr --write-out "%{http_code}" https://${WEBSITE_SITE_NAME}.azurewebsites.net/hubot/keepalive) echo $STATUSCODE [[ $retrys -ne 5 ]] || break echo $retrys ((retrys++)) [[ $STATUSCODE -ne 200 ]] || break done ``` ### Slack Integration Currently the Slack integration has trouble finding hubot when deployed to Azure. Adding a `NODE_PATH` application setting with the value `D:\home\site\wwwroot\node_modules` will allow the Slack module to find hubot. ================================================ FILE: docs/deploying/bluemix.md ================================================ --- title: Deploying to Bluemix layout: layouts/docs.html permalink: /deploying/bluemix.html --- # Deploying to Bluemix If you've been following along with [Getting Started](../index.html), it's time to deploy so you can use it beyond just your local machine. [IBM Bluemix](http://bluemix.net) is a way to deploy hubot. It is built on the open-source project [Cloud Foundry](https://www.cloudfoundry.org/), so we'll be using the `cf cli` throughout these examples. Hubot was originally very closely coupled to Heroku, so there are a couple of things to clean up first that we don't need or that might get in the way on another platform: * remove `Procfile` as we'll create the `manifest.yml` that Bluemix needs in a moment * remove the `hubot-heroku-keepalive` line from `external_scripts.json` and also remove the related npm module (it causes errors on other platforms): npm uninstall --save hubot-heroku-keepalive In preparation for working with Bluemix, install the [Cloud Foundry CLI](https://github.com/cloudfoundry/cli/releases), and create a [Bluemix Account](http://bluemix.net). First we need to define a `manifest.yml` file in the root directory. The contents of the manifest at the bare minimum should look like: ```yml applications: - name: myVeryOwnHubot command: ./bin/hubot --adapter slack instances: 1 memory: 512M ``` In this example, we're using the slack adapter, if you choose slack as your adapter when creating a hubot this will work, otherwise add the `hubot-slack` module to your `package.json`. **Change the name of your hubot in the `manifest.yml` file** because otherwise your application will clash with someone else's who already deployed an app called this! There are many more useful things you can change about your hubot using the manifest file, so check out [these docs](https://docs.cloudfoundry.org/devguide/deploy-apps/manifest.html) for more information. You then need to connect your hubot project to Bluemix: ```sh $ cd your_hubot_project $ cf api https://api.ng.bluemix.net $ cf login ``` Note that the `cf api` command changes per Bluemix region so to deploy somewhere other than "US South", replace this api as appropriate. The `cf login` command will prompt you with your login credentials. Next, we need to set up our environment variables, but we need to create the app first. It won't work properly without the environment variables it needs, so we'll first of all use the `--no-start` flag to deploy but not attempt to start it. ```sh $ cf push NAME_OF_YOUR_HUBOT_APP --no-start ``` Now the app exists, we can set its environment variables. To access slack, you'll need a slack token from the "Apps and Integrations" page; it's visible when you go to create a slackbot. Copy that token and set it as an environment variable called `HUBOT_SLACK_TOKEN`, like this: ```sh $ cf set-env NAME_OF_YOUR_HUBOT_APP HUBOT_SLACK_TOKEN TOKEN_VALUE ``` If you have other environment variables to set, such as configuring the `REDIS_URL` for `hubot-redis-brain`, this is a good time to do that. Finally, we're ready to go! Deploy "for real" this time: ```sh $ cf push NAME_OF_YOUR_HUBOT_APP ``` You should see your bot connect to slack! ### Further Reading - [Deploying Cloud Foundry Apps To Bluemix](https://www.ng.bluemix.net/docs/cfapps/runtimes.html) - [Neploying Node.js Apps to Bluemix](https://www.ng.bluemix.net/docs/starters/nodejs/index.html) - [Setting up your manifest](https://docs.cloudfoundry.org/devguide/deploy-apps/manifest.html) - [Understanding the CF CLI](https://www.ng.bluemix.net/docs/cli/reference/cfcommands/index.html) - [Setting up a Build Pipleline in Bluemix](https://www.ng.bluemix.net/docs/#services/DeliveryPipeline/index.html#getstartwithCD) ### Troubleshooting **Bot doesn't connect** Check your logs for more information using the command `cf logs YOUR_APP_NAME --recent`. If you have NodeJS installed locally, you can also try running the bot on your local machine to inspect any output: simply do `bin/hubot` from the top level of the project. **Bot crashes repeatedly** It is sometimes necessary to to assign more memory to your hubot, depending which plugins you are using (if your app crashes with error 137, try increasing the memory limit). ================================================ FILE: docs/deploying/unix.md ================================================ --- title: Deploying to Unix layout: layouts/docs.html permalink: /deploying/unix.html --- # Deploying to Unix Because there are so many variations of Linux, and more generally UNIX, it's difficult for the hubot team to have canonical documentation for installing and deploying it to every version out there. So, this is an attempt to give an overview of what's needed to get deploying. There are 3 primary things to deploying and running hubot: * node and npm * a way to get source code updated on the server * a way to start hubot, start it up if it crashes, and restart it when code updates ## node and npm To start, your UNIX server will need node and npm. Check out the node.js wiki for [installing Node.js via package manager](https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager), [Building on GNU/Linux and other UNIX](https://github.com/joyent/node/wiki/Installation#building-on-gnulinux-and-other-unix). ## Updating code on the server The simplest way to update your hubot's code is going to be to have a git checkout of your hubot's source code (that you've created during [Getting Started](../index.html), not the [github/hubot repository](http://github.com/github/hubot), and just git pull to get change. This may feel a dirty hack, but it works when you are starting out. If you have a Ruby background, you might be more comfortable using [capistrano](https://github.com/capistrano/capistrano). If you have a [Chef](http://www.chef.io/chef/) background, there's a [deploy](https://docs.chef.io/resource_deploy.html) resource for managing deployments. ## Starting, stopping, and restarting hubot Every hubot install has a `bin/hubot` script to handle starting up the hubot. You can run this command from your git checkout on the server, but there are some problems you can encounter: * you disconnect, and hubot dies * hubot dies, for any reason, and doesn't start again * it doesn't start up at boot automatically For handling you disconnecting, you can start with running `bin/hubot` in [screen session](http://www.gnu.org/software/screen/) or with [nohup](http://linux.die.net/man/1/nohup). For handling hubot dying, and restarting it automatically, you can imagine running `bin/hubot` in a [bash while loop](http://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-7.html#ss7.3). But really, you probably want some process monitoring using tools like [monit](http://mmonit.com/monit/), [god](http://godrb.com/), [bluepill](https://github.com/arya/bluepill), [upstart](http://upstart.ubuntu.com/), [runit](http://smarden.org/runit/), [systemd](http://freedesktop.org/wiki/Software/systemd/). For starting at boot, you can create an init script appropriate for your UNIX distribution. If you are using one of the process monitoring tools above, make sure it boots at startup. See the [examples](https://github.com/github/hubot/tree/main/examples) for configuration examples. ## Recommendations This document has been deliberately light on strong recommendations. At a high level though, it's strongly recommended to avoid anything that is overly manual and non-repeatable. That would mean using your OS's packages and tools whenever possible, and having a proper deploy tool to update hubot, and process management to keep hubot running. ================================================ FILE: docs/deploying/windows.md ================================================ --- title: Deploying to Windows layout: layouts/docs.html permalink: /deploying/windows.html --- # Deploying to Windows Hasn't been fully tested - YMMV There are 4 primary steps to deploying and running hubot on a Windows machine: * node and npm * a way to get source code updated on the server * setting up environment variables for hubot * a way to start hubot, start it up if it crashes, and restart it when code updates ## node and npm To start, your windows server will need node and npm. The best way to do this is with [chocolatey](http://chocolatey.org) using the [nodejs.install](http://chocolatey.org/packages/nodejs.install) package. I've found that sometimes the system path variable is not correctly set; ensure you can run node/npm from the command line. If needed set the PATH variable with `set PATH=%PATH%;\"C:\Program Files\nodejs\"` Your other option is to install directly from [NodeJS](https://nodejs.org/) and run the current download (v0.12.4 as of this documentation). This should set your PATH variables for you. ## Updating code on the server To get the code on your server, you can follow the instructions at [Getting Started](../index.html) on your local development machine or directly on the server. If you are building locally, push your hubot to GitHub and clone the repo onto your server. Don't clone the normal [github/hubot repository](http://github.com/github/hubot), make sure you're using `npx hubot --create myhubot` to build your own hubot. ## Setting up environment vars You will want to set up your hubot environment variables on the server where it will run. You can do this by opening an administrative PowerShell and typing the following: [Environment]::SetEnvironmentVariable("HUBOT_ADAPTER", "Campfire", "Machine") This is equivalent to going into the system menu -> selecting advanced system settings -> environment vars and adding a new system variable called HUBOT_ADAPTER with the value of Campfire. ## Starting, stopping, and restarting hubot Every hubot install has a `bin/hubot` script to handle starting up the hubot. You can run this command directly from your hubot folder by typing the following: .\bin\hubot –adapter campfire There are a few issues if you call it manually, though. * you disconnect, and hubot dies * hubot dies, for any reason, and doesn't start again * it doesn't start up at boot automatically To fix this, you will want to create a .ps1 file with whatever name makes you happy that you will call from your hubot directory. There is a copy of this file in the `examples` directory [here](../../examples/hubot-start.ps1). It should contain the following: Write-Host "Starting Hubot Watcher" While (1) { Write-Host "Starting Hubot" Start-Process powershell -ArgumentList ".\bin\hubot –adapter slack" -wait } Remember to allow local unsigned PowerShell scripts if you are using the .ps1 file to run hubot. Run this command in an Administrator PowerShell window. Set-ExecutionPolicy RemoteSigned You can set this .ps1 as scheduled task on boot if you like or some other way to start your process. ## Expanding the documentation Not yet fleshed out. [Help contribute by submitting a pull request, please?](https://github.com/github/hubot/pull/new/main) ================================================ FILE: docs/deploying.md ================================================ --- title: Deploying layout: layouts/docs.html permalink: /deploying.html --- # Deploying - [Azure](./deploying/azure.html) - [Bluemix](./deploying/bluemix.html) - [Unix](./deploying/unix.html) - [Windows](./deploying/windows.html) ================================================ FILE: docs/designs/commands.md ================================================ You are an expert javascript and Node.js engineer implementing a backwards-compatible command subsystem for Hubot. IMPORTANT: Use TDD. Start by writing tests that describe the behavior, then implement the code to make the tests pass. Do not begin implementation until you have written the tests. Keep changes small and incremental. High-level goal: Add a new deterministic command bus to Hubot via robot.commands while preserving full backwards compatibility with existing robot.respond() and robot.hear() scripts. This must be safe-by-default and must not introduce broad “catch-all” behavior. Constraints: - Minimal dependencies (prefer none). - Defensive coding: invalid input must not crash the bot. - Design for adapter variability (Slack/Discord/etc), but implement renderer as simple text initially. - Existing scripts must run unchanged. Behavioral requirements (what must be true): 1. Backwards compatibility - A bot with legacy listeners (respond/hear) behaves the same after adding robot.commands. - The new subsystem must not consume arbitrary messages. It only intercepts: a) confirmation replies (yes/no/cancel) when a pending confirmation exists for that user+room 2. New API surface implement robot.commands with these capabilities. - Registration with spec and optional opts - handle unregister and update - get command by id, list commands with a filter, get help for a command by id - parse the text into a ParsedInvocation that is nullable - validate with command id, rawArgs and context meta - execute the command by command id, raw args and execution context asynchronously - invoke the invocation text with execution context asynchronously that will parse -> validate -> execute pipeline - propse a given propsal with execution context asynchronously - confirm the users reply text with the execution context asynchronously - prending proposal store keyed by userid + roomid with a TTL # 3. Command Shape A command is registered as: - id - string, uniqueue, required) - description - string - aliases - string[], optional (alternate names that resolve to this command id) - examples - string[], optional - args schema (optional): - arg name: {type, required?, default?, values?} - side effects - string[], optional - confirm policy - "never" | "if_ambiguous" | "always" (optional) - permissions - { rooms?: string[], roles?: string[]} (optional) - handler async function with `{ args, context }` (required) # 4. Canonical invocation parsing Parse messages like: - tickets.create -- title "VPN down" --priority high --assignee matt --room #ops - also support key:value tokens - tickets.create title:"VPN down" prioriy:high - also support command aliases (resolved by parse() to their canonical id) - if tickets.create has alias 'ticket.new', then ticket.new also works - Must support quoted strings, bare words, boolean flags # 5. Validation and normalization - Apply defaults from schema - Enforce required args - Validate enums - Support types: string, number, boolean, enum, user, room, date - Built-in resolvers: - user: attempt to map a string to a Hubot user from robot.brain.users() - room: validate #room format - date: support today, tomorrow, ISO, and “YYYY-MM-DD HH:mm” (keep it light); Return ValidationResult with: - ok true + normalized args - ok false + missing + errors + (if possible) one clarifyingQuestion # 6. Permissions - If permissions.rooms exists, deny execution outside allowed rooms. - Roles are optional; implement a pluggable permission provider hook. Default allow if not configured. # 7. Confirmation - If sideEffects exist OR confirm=always, require confirmation before executing. - Confirmation uses propose→confirm pipeline: - propose() returns a preview of the canonical invocation and asks “Run it? (yes/no)” - confirm() executes on yes, cancels on no/cancel - confirm() only triggers if there is a pending confirmation for that user+room key. # 8. Middleware Integration The command subsystem integrates with Hubot's receive middleware to intercept and handle commands: - **Confirmation Middleware**: Intercepts confirmation replies (yes/no/cancel) when a pending proposal exists for that user+room - **Invocation Middleware**: Intercepts command invocations (text starting with command ID or alias) to parse, validate, and execute These middleware handlers are implemented in `Robot.setupCommandListeners()` and must not interfere with normal chat or legacy `hear`/`respond` listeners. The middleware only acts on addressed messages (those directed at the robot) and commands that have been explicitly registered. # 9. Bridging (optional but useful) Allow register(spec, { bridge: “respond” | “hear” | “none” }) such that when bridge is enabled, a legacy listener is registered that delegates into the command system (but still does not swallow normal chat). # 10. Observability Emit events through an EventEmitter on robot.commands: - commands:registered - commands:updated - commands:alias_collision_detected - commands:invocation_parsed - commands:validation_failed - commands:proposal_created - commands:proposal_confirm_requested - commands:proposal_confirmed - commands:proposal_cancelled - commands:executed - commands:permission_denied - commands:error **Logging Strategy**: Fire-and-forget async logging to NDJSON file (`default: .data/commands-events.ndjson`). Each event is written individually and asynchronously. Set `disableLogging: true` during testing to prevent file I/O. # TDD plan you must follow: A) Write tests first. Include at least these test scenarios: - parse() correctly parses quoted args and key:value args- parse() resolves command aliases to canonical command ids- validate() applies defaults and rejects invalid enum - user resolver maps a name to a brain user record (use a stub brain) - propose() creates pending confirmation for side-effect command - confirm(“yes”) executes, confirm(“no”) cancels - confirmation listener does nothing if no pending exists B) Only after tests exist, implement the smallest code to pass them. C) Refactor as needed to keep code clean, depulication, software design that provides affordances. Environment for tests: - Use Node’s built-in test runner (node:test) unless you have a strong reason not to. - Create minimal “fake robot” and “fake message” objects as needed; do not require a real chat adapter. You can create a dumby one. - Because we're in an asynchronous environment, tests often "hange". so make sure to use --test-timeout when running node --test or npm test. Output format: - Provide the full repository output (tests + implementation + a short README). - Do not over-engineer; build a clean minimal core with extension hooks. ================================================ FILE: docs/docs.md ================================================ --- title: Getting Started With Hubot layout: layouts/docs.html published: 2023-10-10T19:25:22.000Z permalink: /docs.html --- # Getting Started With Hubot You will need [node.js and npm](https://docs.npmjs.com/getting-started/installing-node). Once those are installed, you can setup a new codebase for a new Hubot instance with the following shell commands. It will create a new directory called `myhubot` in the current working directory. ```sh npx hubot --create myhubot ``` Now open `package.json` in your code editor and add a `start` property to the `scripts` property: ```json { ... "scripts": { "start": "hubot" } ... } ``` Start your Hubot instance by executing `npm start`. It will start with the built in [Shell adapter](/adapters/shell.html), which starts a [REPL](https://en.wikipedia.org/wiki/Read–eval–print_loop) where you can type commands. Your terminal should look like: ```sh Hubot> ``` Typing `help` will list some default commands that Hubot's default adapter, Shell, can handle. ```sh Hubot> help usage: history exit, \q - close Shell and exit help, \? - print this usage clear, \c - clear the terminal screen Hubot> ``` Changing your Hubot instances name will reduce confusion down the road, so set the `--name` argument in the `hubot` command: ```json { ... "scripts": { "start": "hubot --name sam" } ... } ``` Your hubot will now respond as `sam`. Note, a common usage pattern is prefixing a command message with Hubot's name. Hubot's code pattern matches on it in order to trigger sending it to Hubot. This is case-insensitive, and can be prefixed with `@` or suffixed with `:`. The following examples result in the message getting sent to Hubot. ```sh sam> SAM help sam> sam help sam> @sam help sam> sam: help ``` ## Scripts Hubot's power comes through scripts. There are hundreds of scripts written and maintained by the community. Find them by searching the [NPM registry](https://www.npmjs.com/browse/keyword/hubot-scripts) for `hubot-scripts `. For example: ```sh $ npm search hubot-scripts github NAME DESCRIPTION hubot-deployer Giving Hubot the ability to deploy GitHub repos to PaaS providers hubot hubot-scripts hubot-gith hubot-gh-release-pr A hubot script to create GitHub's PR for release hubot-github Giving Hubot the ability to be a vital member of your github organization ``` To use a script from an NPM package: 1. Run `npm install ` in the codebase directory to install it. 2. Add the package name to a file called `external-scripts.json`. ```json ["hubot-diagnostics", "hubot-help"] ``` 3. Run `npm home ` to open a browser window for the homepage of the script, where you can find more information about configuring and installing the script. You can also create your own scripts and save them in a folder called `./scripts/` (`./` means current working directory) in your codebase directory. All scripts (files ending with `.js` and `.mjs`) placed there are automatically loaded and ready to use with your hubot. Read more about customizing hubot by [writing your own scripts](scripting.html). ## Adapters Hubot uses the adapter pattern to support multiple chat-backends. Here is a [list of available adapters](adapters.html), along with details on how to configure them. Please note that Hubot is undergoing major changes and old adapters may no longer work with the latest version of Hubot (anything after 3.5). ## Deploying You are able to deploy hubot to a UNIX-like system or Windows. Please note the support for deploying to Windows isn't officially supported. * [Deploying Hubot onto Azure](./deploying/azure.html) * [Deploying Hubot onto Bluemix](./deploying/bluemix.html) * [Deploying Hubot onto Unix](./deploying/unix.html) * [Deploying Hubot onto Windows](./deploying/windows.html) ## Redis Hubot can use Redis to persist data, so if you want to persist data, then you should have Redis running on your machine accessible via `localhost`. Then, ensure that `hubot-redis-brain` is listed in `external-scripts.json` as an `Array` of module names (e.g. `["hubot-redis-brain"]`) or an `object` where the key is the name of the module (e.g. `{"hubot-redis-brain": "some arbitrary value"}`) where the value of the property in the object is passed to the module function as the second argument. The first argument being the hubot Robot instance. An example `external-scripts.json` file might look like the following: ```json ["hubot-redis-brain", "hubot-help", "hubot-diagnostics"] ``` or ```json { "hubot-redis-brain": "some arbitrary value", "hubot-help": "this value will be sent to the hubot-help module", "hubot-diagnostics": { "name": "test", "age": "21" } } ``` ## Patterns Using custom scripts, you can quickly customize Hubot to be the most life embettering robot he or she can be. Read [docs/patterns](patterns.html) for some nifty tricks that may come in handy as you teach your hubot new skills. ================================================ FILE: docs/implementation.md ================================================ --- title: Implementation Notes layout: layouts/docs.html permalink: /implementation.html --- # Implementation For the purpose of maintainability, several internal flows are documented here. ## Message Processing When a new message is received by an adapter, a new Message object is constructed and passed to `robot.receive` (async). `robot.receive` then attempts to execute each Listener in order of registration by calling `listener.call` (async), passing in the Listener Middleware stack. `listener.call` first checks to see if the listener matches (`match` method, sync), and if so, calls `middleware.execute` (async) on the provided middleware. `middleware.execute` calls each middleware in order of registration. Middleware can either continue forward (call `next`) or abort (call `done`). If all middleware continues, `middleware.execute` calls `next` (the `listener.call` callback). If any middleware aborts, `middleware.execute` calls `done` (which eventually returns to the `robot.receive` callback). `middleware.execute` `next` returns to `listener.call`, which executes the matched Listener's callback and then calls the `robot.receive` callback. Inside the `robot.receive` processing loop, `message.done` is checked after each `listener.call`. If the message has been marked as done, `robot.receive` returns. This correctly handles asynchronous middleware, but will not catch an asynchronous set of `message.done` inside the listener callback (which is expected to be synchronous). If no listener matches the message (distinct from setting `message.done`), a CatchAllMessage is created which wraps the original message. This new message is run through all listeners again testing for a match. `robot.catchAll` creates a special listener that only matches CatchAllMessages. ## Listeners Listeners are registered using several functions on the `robot` object: `hear`, `respond`, `enter`, `leave`, `topic`, and `catchAll`. A listener is used via its `call` method, which is responsible for testing to see if a message matches (`match` is an abstract method) and if so, executes the listener's callback. Listener callbacks are assumed to be synchronous. ## Middleware There are two primary entry points for middleware: 1. `robot.listenerMiddleware` - registers a new piece of middleware in a global array 2. `middleware.execute` - executes all registered middleware in order ## Persistence ### Brain Hubot has a memory exposed as the `robot.brain` object that can be used to store and retrieve data. Furthermore, Hubot scripts exist to enable persistence across Hubot restarts. `hubot-redis-brain` is such a script and uses a backend Redis server. By default, the brain contains a list of all users seen by Hubot. Therefore, without persistence across restarts, the brain will contain the list of users encountered so far, during the current run of Hubot. On the other hand, with persistence across restarts, the brain will contain all users encountered by Hubot during all of its runs. This list of users can be accessed through `hubot.brain.users()` and other utility methods. ### Datastore Hubot's optional datastore, exposed as the `robot.datastore` object, provides a more robust persistence model. Compared to the brain, the datastore: 1. Is always (instead of optionally) backed by a database 2. Fetches data from the database and stores data in the database on every request, instead of periodically persisting the entire in-memory brain. The datastore is useful in cases where there's a need for greater reassurances of data integrity or in cases where multiple Hubot instances need to access the same database. ================================================ FILE: docs/index.html ================================================ {{#> layouts/main.html}}
hubot schematic

Fig. 1 — Hubot Schematics

What is Hubot?

Hubot is your friendly robot sidekick. Install him in your company to dramatically improve employee efficiency.

No seriously, what is Hubot?

GitHub, Inc., wrote the first version of Hubot to automate our company chat room. Hubot knew how to deploy the site, automate a lot of tasks, and be a source of fun around the office. Eventually he grew to become a formidable force in GitHub, but he led a private, messy life. So we rewrote him.

Today's version of Hubot is open source, written in JavaScript on Node.js, and easily deployed on computers. More importantly, Hubot is a standardized way to share scripts between everyone's robots.

What can Hubot do?

We ship Hubot with a small group of core scripts: things like posting images, translating languages, and integrating with Google Maps. We also maintain a repository of community Hubot scripts and an organization of community Hubot packages that you can add to your own robot.

The real fun happens when you add your own scripts. Be sure to personalize your Hubot, too; your company's robot should be a place full of inside jokes, custom integrations, and general merriment.

How do I write my own Hubot scripts?

Check out this documentation for writing your own Hubot scripts. Then the sky's the limit; just add them to your generated `scripts` directory.

If you write a Hubot script for taking over the world, please let us know.

{{/layouts/main.html}} ================================================ FILE: docs/layouts/docs.html ================================================ {{ title }}

Built with <3 by friends of Hubot

================================================ FILE: docs/layouts/main.html ================================================ {{ title }}
hubot logo

Hubot (note: it's prounounced hew-bot)

A Customizable,
Life Embetterment Robot

Commissioned by

{{> @partial-block }}

Built with <3 by friends of Hubot

================================================ FILE: docs/patterns.md ================================================ --- title: Patterns layout: layouts/docs.html should_publish: yes published: 2023-10-10T19:25:22.000Z permalink: /patterns.html --- # Patterns Shared patterns for dealing with common Hubot scenarios. ## Renaming the Hubot instance When you rename Hubot, he will no longer respond to his former name. In order to train your users on the new name, you may choose to add a deprecation notice when they try to say the old name. The pattern logic is: * listen to all messages that start with the old name * reply to the user letting them know about the new name Setting this up is very easy: 1. Create a [bundled script](scripting.html) in the `scripts/` directory of your Hubot instance called `rename-hubot.js` 2. Add the following code, modified for your needs: ```javascript // Description: // Tell people hubot's new name if they use the old one // Commands: // None export default async (robot) => { robot.hear(/^hubot:? (.+)/i, async (res) => { let response = `Sorry, I'm a diva and only respond to ${robot.name}` response += robot.alias ? ` or ${robot.alias}` : '' return await res.reply(response) }) } ``` In the above pattern, modify both the hubot listener and the response message to suit your needs. Also, it's important to note that the listener should be based on what hubot actually hears, instead of what is typed into the chat program before the Hubot Adapter has processed it. For example, the [HipChat Adapter](https://github.com/hipchat/hubot-hipchat) converts `@hubot` into `hubot:` before passing it to Hubot. ## Deprecating or Renaming Listeners If you remove a script or change the commands for a script, it can be useful to let your users know about the change. One way is to just tell them in chat or let them discover the change by attempting to use a command that no longer exists. Another way is to have Hubot let people know when they've used a command that no longer works. This pattern is similar to the Renaming the Hubot Instance pattern above: * listen to all messages that match the old command * reply to the user letting them know that it's been deprecated Here is the setup: 1. Create a [bundled script](scripting.html) in the `scripts/` directory of your Hubot instance called `deprecations.js` 2. Copy any old command listeners and add them to that file. For example, if you were to rename the help command for some silly reason: ```javascript // Description: // Tell users when they have used commands that are deprecated or renamed // // Commands: // None // export default async (robot) => { robot.respond(/help\s*(.*)?$/i, async (res) => { return await res.reply('That means nothing to me anymore. Perhaps you meant "docs" instead?') }) } ``` ## Preventing Hubot from Running Scripts Concurrently Sometimes you have scripts that take several minutes to execute. If these scripts are doing something that could be interfered with by running subsequent commands, you may wish to code your scripts to prevent concurrent access. To do this, you can set up a lock in the Hubot [brain](scripting.html#persistence) object. The lock is set up here so that different scripts can share the same lock if necessary. Setting up the lock looks something like this: ```javascript export default async (robot) => { robot.brain.on('loaded', () => { // Clear the lock on startup in case Hubot has restarted and Hubot's brain has persistence (e.g. redis). // We don't want any orphaned locks preventing us from running commands. robot.brain.remove('yourLockName') }) robot.respond(/longrunningthing/i, async (msg) => { const lock = robot.brain.get('yourLockName') if (lock) { return await msg.send(`I'm sorry, ${msg.message.user.name}, I'm afraid I can't do that. I'm busy doing something for ${lock.user.name}.`) } robot.brain.set('yourLockName', msg.message) // includes user, room, etc about who locked try { await yourLongClobberingAsyncThing() // Clear the lock robot.brain.remove('yourLockName') await msg.reply('Finally Done') } catch (e) { console.error(e) } }) } ``` ## Forwarding all HTTP requests through a proxy In many corporate environments, a web proxy is required to access the Internet and/or protected resources. For one-off control, use can specify an [Agent](https://nodejs.org/api/http.html) to use with `robot.http`. However, this would require modifying every script your robot uses to point at the proxy. Instead, you can specify the agent at the global level and have all HTTP requests use the agent by default. Due to the way Node.js handles HTTP and HTTPS requests, you need to specify a different Agent for each protocol. ScopedHTTPClient will then automatically choose the right ProxyAgent for each request. 1. Install ProxyAgent. `npm install proxy-agent` 2. Create a [bundled script](scripting.html) in the `scripts/` directory of your Hubot instance called `proxy.js` 3. Add the following code, modified for your needs: ```javascript import proxy from 'proxy-agent' export default async (robot) => { robot.globalHttpOptions.httpAgent = proxy('http://my-proxy-server.internal', false) robot.globalHttpOptions.httpsAgent = proxy('http://my-proxy-server.internal', true) } ``` ## Dynamic matching of messages In some situations, you want to dynamically match different messages (e.g. factoids, JIRA projects). Rather than defining an overly broad regular expression that always matches, you can tell Hubot to only match when certain conditions are met. In a simple robot, this isn't much different from just putting the conditions in the Listener callback, but it makes a big difference when you are dealing with middleware: with the basic model, middleware will be executed for every match of the generic regex. With the dynamic matching model, middleware will only be executed when the dynamic conditions are matched. For example, the [factoid lookup command](https://github.com/github/hubot-scripts/blob/bd810f99f9394818a9dcc2ea3729427e4101b96d/src/scripts/factoid.coffee#L95-L99) could be reimplemented as: ```javascript // use case: Hubot>fact1 // This listener doesn't require you to type the bot's name first import {TextMessage} from '../src/message.mjs' export default async (robot) => { // Dynamically populated list of factoids const facts = { fact1: 'stuff', fact2: 'other stuff' } robot.listen( // Matcher (message) => { // Check that message is a TextMessage type because // if there is no match, this matcher function will // be called again but the message type will be CatchAllMessage // which doesn't have a `match` method. if(!(message instanceof TextMessage)) return false const match = message.match(/^(.*)$/) // Only match if there is a matching factoid if (match && match[1] in facts) { return match[1] } else { return false } }, // Callback async (res) => { const fact = res.match await res.reply(`${fact} is ${facts[fact]}`) } ) } ``` ## Restricting access to commands One of the awesome features of Hubot is its ability to make changes to a production environment with a single chat message. However, not everyone with access to your chat service should be able to trigger production changes. There are a variety of different patterns for restricting access that you can follow depending on your specific needs: * Two buckets of access: full and restricted with include/exclude list * Specific access rules for every command (Role-based Access Control) * Include/exclude listing commands in specific rooms ### Simple per-listener access In some organizations, almost all employees are given the same level of access and only a select few need to be restricted (e.g. new hires, contractors, etc.). In this model, you partition the set of all listeners to separate the "power commands" from the "normal commands". Once you have segregated the listeners, you need to make some tradeoff decisions around include/exclude users and listeners. The key deciding factors for inclusion vs exclusion of users are the number of users in each category, the frequency of change in either category, and the level of security risk your organization is willing to accept. * Including users (users X, Y, Z have access to power commands; all other users only get access to normal commands) is a more secure method of access (new users have no default access to power commands), but has higher maintenance overhead (you need to add each new user to the "include" list). * Excluding users (all users get access to power commands, except for users X, Y, Z, who only get access to normal commands) is a less secure method (new users have default access to power commands until they are added to the exclusion list), but has a much lower maintenance overhead if the exclusion list is small/rarely updated. The key deciding factors for selectively allowing vs restricting listeners are the number of listeners in each category, the ratio of internal to external scripts, and the level of security risk your organization is willing to accept. * Selectively allowing listeners (all listeners are power commands, except for listeners A, B, C, which are considered normal commands) is a more secure method (new listeners are restricted by default), but has a much higher maintenance overhead (every silly/fun listener needs to be explicity downgraded to "normal" status). * Selectively restricting listeners (listeners A, B, C are power commands, everything else is a normal command) is a less secure method (new listeners are put into the normal category by default, which could give unexpected access; external scripts are particularly risky here), but has a lower maintenance overhead (no need to modify/enumerate all the fun/culture scripts in your access policy). As an additional consideration, most scripts do not currently have listener IDs, so you will likely need to open PRs (or fork) any external scripts you use to add listener IDs. The actual modification is easy, but coordinating with lots of maintainers can be time consuming. Once you have decided which of the four possible models to follow, you need to build the appropriate lists of users and listeners to plug into your authorization middleware. Example: inclusion list of users given access to selectively restricted power commands ```javascript const POWER_COMMANDS = [ 'deploy.web' // String that matches the listener ID ] // Change name to something else to see it reject the command. const POWER_USERS = [ 'Shell' // String that matches the user ID set by the adapter ] export default async (robot) => { robot.listenerMiddleware(async (context) => { if (POWER_COMMANDS.indexOf(context.listener.options.id) > -1) { if (POWER_USERS.indexOf(context.response.message.user.name) > -1){ // User is allowed access to this command return true } else { // Restricted command, but user isn't in whitelist await context.response.reply(`I'm sorry, @${context.response.message.user.name}, but you don't have access to do that.`) return false } } else { // This is not a restricted command; allow everyone return true } }) robot.listen(message => { return true }, {id: 'deploy.web'}, async res => { await res.reply('Deploying web...') }) } ``` Remember that middleware executes for ALL listeners that match a given message (including `robot.hear(/.+/)`), so make sure you include them when categorizing your listeners. ### Specific access rules per listener For larger organizations, a binary categorization of access is usually insufficient and more complex access rules are required. Example access policy: * Each development team has access to cut releases and deploy their service * The Operations group has access to deploy all services (but not cut releases) * The front desk cannot cut releases nor deploy services Complex policies like this are currently best implemented in code directly. ### Specific access rules per room Organizations that have a number of chat rooms that serve different purposes often want to be able to use the same instance of hubot but have a different set of commands allowed in each room. Work on generalized exlusion list solution is [ongoing](https://github.com/kristenmills/hubot-command-blacklist). An inclusive list soultion could take a similar approach. ## Use scoped npm packages as adapter It is possible to [install](https://docs.npmjs.com/cli/v7/commands/npm-install) package under a custom alias: ```bash npm install @npm: ``` So for example to use `@foo/hubot-adapter` package as the adapter, you can: ```bash npm install hubot-foo@npm:@foo/hubot-adapter bin/hubot --adapter foo ``` ================================================ FILE: docs/scripting.md ================================================ --- title: Scripting layout: layouts/docs.html should_publish: yes published: 2023-10-10T19:25:22.000Z permalink: /scripting.html --- # Scripting Hubot out of the box doesn't do too much, but it is an extensible, scriptable robot friend. There are [hundreds of scripts written and maintained by the community](docs.html#scripts) and it's easy to write your own. You can create a custom script in Hubot's `scripts` directory or [create a script package](#creating-a-script-package) for sharing with the community! ## Anatomy of a script When you created your Hubot, the generator also created a `scripts` directory. If you peek around there, you will see some examples. For a script to be a script, it needs to: * live in a directory on the Hubot script load path (`src/scripts` and `scripts` by default) * be a `.js` or `.mjs` file * export a function whos signature takes 1 parameter (`robot`) By export a function, we just mean: ```javascript // .mjs export default async robot => { // your code here } ``` ```javascript // .js module.exports = async robot => { // your code here } ``` The `robot` parameter is an instance of your robot friend. At this point, we can start scripting up some awesomeness. ## Adding Configuration The loading code loads files as the following and in this order: - ./configuration <- so you can add configuration options to the `robot` instance that the Adapters can then use. - Then it loads the adapter - ./scripts - ./src/scripts - Then the modules defined in `external-scripts.json` ## Hearing and responding Since this is a chat bot, the most common interactions are based on messages. Hubot can `hear` messages said in a room or `respond` to messages directly addressed at it. Both methods take a regular expression and a callback function as parameters. For example: ```javascript // .mjs export default async robot => { robot.hear(/badger/i, async res => { // your code here }) robot.respond(/open the pod bay doors/i, async res => { // your code here } } ``` The `robot.hear(/badger/)` callback is called anytime a message's text matches. For example: * Stop badgering the witness * badger me * what exactly is a badger anyways The `robot.respond(/open the pod bay doors/i)` callback is only called for messages that are immediately preceded by the robot's name or alias. If the robot's name is HAL and alias is /, then this callback would be triggered for: * hal open the pod bay doors * HAL: open the pod bay doors * @HAL open the pod bay doors * /open the pod bay doors It wouldn't be called for: * HAL: please open the pod bay doors * because its `respond` is expecting the text to be prefixed with the robots name * has anyone ever mentioned how lovely you are when you open the pod bay doors? * because it lacks the robot's name at the beginning ## Send & reply The `res` parameter is an instance of `Response` (historically, this parameter was `msg` and you may see other scripts use it this way). With it, you can `send` a message back to the room the `res` came from, `emote` a message to a room (If the given adapter supports it), or `reply` to the person that sent the message. For example: ```javascript // .mjs export default async robot => { robot.hear(/badger/i, async res => { res.send(`Badgers? BADGERS? WE DON'T NEED NO STINKIN BADGERS`) } robot.respond(/open the pod bay doors/i, async res => { res.reply(`I'm afraid I can't let you do that.`) } robot.hear(/I like pie/i, async res => { res.emote('makes a freshly baked pie') } } ``` The `robot.hear(/badgers/)` callback sends a message exactly as specified regardless of who said it, "Badgers? BADGERS? WE DON'T NEED NO STINKIN BADGERS". If a user Dave says "HAL: open the pod bay doors", `robot.respond(/open the pod bay doors/i)` callback sends a message "Dave: I'm afraid I can't let you do that." ## Messages to a room or user Messages can be sent to a specified room or user using the messageRoom function. ```javascript // .mjs export default async robot => { robot.hear(/green eggs/i, async res => { const room = 'mytestroom' await robot.messageRoom(room, 'I do not like green eggs and ham. I do not like them Sam-I-Am.') } } ``` User name can be explicitely specified if desired ( for a cc to an admin/manager), or using the response object a private message can be sent to the original sender. ```javascript robot.respond(/I don't like sam-i-am/i, async res => { const room = 'joemanager' await robot.messageRoom(room, 'Someone does not like Dr. Seus') await res.reply('That Sam-I-Am\nThat Sam-I-Am\nI do not like\nthat Sam-I-Am') } robot.hear(/Sam-I-Am/i, async res => { const room = res.envelope.user.name await robot.messageRoom(room, 'That Sam-I-Am\nThat Sam-I-Am\nI do not like\nthat Sam-I-Am') } ``` ## Capturing data So far, our scripts have had static responses, which while amusing, are boring functionality-wise. `res.match` has the result of `match`ing the incoming message against the regular expression. This is just a [JavaScript thing](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match), which ends up being an array with index 0 being the full text matching the expression. If you include capture groups, those will be populated on `res.match`. For example, if we update a script like: ```javascript robot.respond(/open the (.*) doors/i, async res => { // your code here } ``` If Dave says "HAL: open the pod bay doors", then `res.match[0]` is "open the pod bay doors", and `res.match[1]` is just "pod bay". Now we can start doing more dynamic things: ```javascript robot.respond(/open the (.*) doors/i, async res => { const doorType = res.match[1] if (doorType == 'pod bay') { await res.reply(`I'm afraid I can't let you do that.`) } else { await res.reply(`Opening ${doorType} doors`) } } ``` ## Making HTTP calls (please use `fetch` instead) Hubot can make HTTP calls on your behalf to integrate & consume third party APIs. This can be through an instance of [ScopedHttpClient](https://github.com/hubotio/hubot/blob/main/src/httpclient.js) available at `robot.http`. The simplest case looks like: ```javascript robot.http('https://midnight-train').get()((err, res, body) => { // your code here }) ``` A post looks like: ```javascript const data = JSON.stringify({ foo: 'bar' }) robot.http('https://midnight-train') .header('Content-Type', 'application/json') .post(data)((err, res, body) => { // your code here }) ``` `err` is an error encountered on the way, if one was encountered. You'll generally want to check for this and handle accordingly: ```javascript robot.http('https://midnight-train') .get()((err, res, body) => { if (err){ return res.send `Encountered an error :( ${err}` } // your code here, knowing it was successful }) ``` `res` is an instance of node's [http.ServerResponse](http://nodejs.org/api/http.html#http_class_http_serverresponse). Most of the methods don't matter as much when using `ScopedHttpClient`, but of interest are `statusCode` and `getHeader`. Use `statusCode` to check for the HTTP status code, where usually non-200 means something bad happened. Use `getHeader` for peeking at the header, for example to check for rate limiting: ```javascript robot.http('https://midnight-train') .get() ((err, res, body) => { // pretend there's error checking code here if (res.statusCode <> 200) return res.send(`Request didn't come back HTTP 200 :(`) const rateLimitRemaining = res.getHeader('X-RateLimit-Limit') ? parseInt(res.getHeader('X-RateLimit-Limit')) : 1 if (rateLimitRemaining && rateLimitRemaining < 1) return res.send('Rate Limit hit, stop believing for awhile') // rest of your code } ``` `body` is the response's body as a string, the thing you probably care about the most: ```javascript robot.http('https://midnight-train') .get()((err, res, body) => { // error checking code here res.send(`Got back ${body}`) }) ``` ### JSON If you are talking to Web Services that respond with JSON representation, then when making the `robot.http` call, you will usually set the `Accept` header to give the Web Service a clue that's what you are expecting back. Once you get the `body` back, you can parse it with `JSON.parse`: ```javascript robot.http('https://midnight-train') .header('Accept', 'application/json') .get()((err, res, body) => { // error checking code here const data = JSON.parse(body) res.send(`${data.passenger} taking midnight train going ${data.destination}`) }) ``` It's possible to get non-JSON back, like if the Web Service has an error and renders HTML instead of JSON. To be on the safe side, you should check the `Content-Type`, and catch any errors while parsing. ```javascript robot.http('https://midnight-train') .header('Accept', 'application/json') .get()((err, res, body) => { // err & res status checking code here if (res.getHeader('Content-Type') != 'application/json'){ return res.send(`Didn't get back JSON :(`) } let data = null try { data = JSON.parse(body) } catch (error) { res.send(`Ran into an error parsing JSON :(`) } // your code here }) ``` ### XML XML Web Services require installing a XML parsing library. It's beyond the scope of this documentation to go into detail, but here are a few libraries to check out: * [xml2json](https://github.com/buglabs/node-xml2json) (simplest to use, but has some limitations) * [jsdom](https://github.com/tmpvar/jsdom) (JavaScript implementation of the W3C DOM) * [xml2js](https://github.com/Leonidas-from-XIV/node-xml2js) ### Screen scraping For consuming a Web Service that responds with HTML, you'll need an HTML parser. It's beyond the scope of this documentation to go into detail, but here's a few libraries to check out: * [cheerio](https://github.com/MatthewMueller/cheerio) (familiar syntax and API to jQuery) * [jsdom](https://github.com/tmpvar/jsdom) (JavaScript implementation of the W3C DOM) ### Advanced HTTP and HTTPS settings As mentioned previously, Hubot uses [ScopedHttpClient](https://github.com/hubotio/hubot/blob/main/src/httpclient.js) to provide a simple interface for making HTTP and HTTPS requests. Under the hood, it's using node's [http](http://nodejs.org/api/http.html) and [https](http://nodejs.org/api/https.html) modules, but tries to provide an easier Domain Specific Language (DSL) for common kinds of Web Service interactions. If you need to control options on `http` and `https` more directly, you pass a second parameter to `robot.http` that will be passed on to `ScopedHttpClient` which will be passed on to `http` and `https`: ```javascript const options = { rejectUnauthorized: false // don't verify server certificate against a CA, SCARY! } robot.http('https://midnight-train', options) ``` In addition, if `ScopedHttpClient` doesn't suit you, you can use [http](http://nodejs.org/api/http.html), [https](http://nodejs.org/api/https.html) or `fetch` directly. ## Random A common pattern is to hear or respond to commands, and send with a random funny image or line of text from an array of possibilities. Hubot includes a convenience method: ```javascript const lulz = ['lol', 'rofl', 'lmao'] res.send(res.random(lulz)) ``` ## Topic Hubot can react to a room's topic changing, assuming that the adapter supports it. ```javascript // .mjs export default async robot => { robot.topic(async res => { await res.send()`${res.message.text}? That's a Paddlin'`) }) } ``` ## Entering and leaving Hubot can see users entering and leaving, assuming that the adapter supports it. ```javascript // .mjs const enterReplies = ['Hi', 'Target Acquired', 'Firing', 'Hello friend.', 'Gotcha', 'I see you'] const leaveReplies = ['Are you still there?', 'Target lost', 'Searching'] export default async robot => { robot.enter(async res => { await res.send(res.random(enterReplies)) }) robot.leave(async res => { await res.send(res.random(leaveReplies)) }) } ``` ## Custom Listeners While the above helpers cover most of the functionality the average user needs (hear, respond, enter, leave, topic), sometimes you would like to have very specialized matching logic for listeners. If so, you can use `listen` to specify a custom match function instead of a regular expression. The match function must return a truthy value if the listener callback should be executed. The truthy return value of the match function is then passed to the callback as `res.match`. ```javascript // .mjs export default async robot =>{ robot.listen( (message) => { // Match function // only match messages with text (ie ignore enter and other events) if(!message?.text) return // Occassionally respond to things that Steve says return message.user.name == 'Steve' && Math.random() > 0.8 }, async res => { // Standard listener callback // Let Steve know how happy you are that he exists await res.reply(`HI STEVE! YOU'RE MY BEST FRIEND! (but only like ${res.match * 100}% of the time)`) } ) } ``` See [the design patterns document](patterns.html#dynamic-matching-of-messages) for examples of complex matchers. ## Environment variables Hubot can access the environment he's running in, just like any other Node.js program, using [`process.env`](http://nodejs.org/api/process.html#process_process_env). This can be used to configure how scripts are run, with the convention being to use the `HUBOT_` prefix. ```javascript // .mjs const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING export default async robot => { robot.respond(/what is the answer to the ultimate question of life/, async res => { await res.send(`${answer}, but what is the question?`) }) } ``` Take care to make sure the script can load if it's not defined, give the Hubot developer notes on how to define it, or default to something. It's up to the script writer to decide if that should be a fatal error (e.g. hubot exits), or not (make any script that relies on it to say it needs to be configured. When possible and when it makes sense to, having a script work without any other configuration is preferred. Here we can default to something: ```javascript // .mjs const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING ?? 42 export default async robot => { robot.respond(/what is the answer to the ultimate question of life/, async res => { await res.send(`${answer}, but what is the question?`) }) } ``` Here we exit if it's not defined: ```javascript // .mjs const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING if(!answer) { console.log(`Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again`) process.exit(1) } export default async robot => { robot.respond(/what is the answer to the ultimate question of life/, async res => { await res.send(`${answer}, but what is the question?`) }) } ``` And lastly, we update the `robot.respond` to check it: ```javascript // .mjs const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING export default async robot => { robot.respond(/what is the answer to the ultimate question of life/, async res => { if(!answer) { return await res.send('Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again') } await res.send(`${answer}, but what is the question?`) }) } ``` ## Dependencies Hubot uses [npm](https://www.npmjs.com) to manage its dependencies. To add additional packages, add them to `dependencies` in `package.json`. For example, to add lolimadeupthispackage 1.2.3, it'd look like: ```json "dependencies": { "hubot": "2.5.5", "lolimadeupthispackage": "1.2.3" }, ``` by executing `npm i lolimadeupthispackage@1.2.3`. If you are using scripts from hubot-scripts, take note of the `Dependencies` documentation in the script to add. They are listed in a format that can be copy & pasted into `package.json`, just make sure to add commas as necessary to make it valid JSON. # Timeouts and Intervals Hubot can run code later using JavaScript's built-in [setTimeout](http://nodejs.org/api/timers.html#timers_settimeout_callback_delay_arg). It takes a callback method, and the amount of time to wait before calling it: ```javascript // .mjs export default async robot => { robot.respond(/you are a little slow/, async res => { setTimeout(async () => { await res.send(`Who you calling 'slow'?`) }, 60 * 1000) }) } ``` Additionally, Hubot can run code on an interval using [setInterval](http://nodejs.org/api/timers.html#timers_setinterval_callback_delay_arg). It takes a callback method, and the amount of time to wait between calls: ```javascript // .mjs export default async robot => { robot.respond(/annoy me/, async res => { await res.send('Hey, want to hear the most annoying sound in the world?') setInterval(async () => { await res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH') }, 1000) }) } ``` Both `setTimeout` and `setInterval` return the ID of the timeout or interval it created. This can be used to to `clearTimeout` and `clearInterval`. ```javascript // .mjs export default async robot => { let annoyIntervalId = null robot.respond(/annoy me/, async res => { if (annoyIntervalId) { return await res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH') } await res.send('Hey, want to hear the most annoying sound in the world?') annoyIntervalId = setInterval(async () => { await res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH') }, 1000) } robot.respond(/unannoy me/, async res => { if (annoyIntervalId) { await res.send('GUYS, GUYS, GUYS!') clearInterval(annoyIntervalId) annoyIntervalId = null } else { await res.send('Not annoying you right now, am I?') } } } ``` ## HTTP Listener Hubot includes support for the [express](http://expressjs.com) web framework to serve up HTTP requests. It listens on the port specified by the `EXPRESS_PORT` or `PORT` environment variables (preferred in that order) and defaults to 8080. An instance of an express application is available at `robot.router`. It can be protected with username and password by specifying `EXPRESS_USER` and `EXPRESS_PASSWORD`. It can automatically serve static files by setting `EXPRESS_STATIC`. You can increase the [maximum request body size](https://github.com/expressjs/body-parser#limit-3) by specifying `EXPRESS_LIMIT`. It defaults to '100kb'. You can also set the [maximum number of parameters](https://github.com/expressjs/body-parser#parameterlimit) that are allowed in the URL-encoded data by setting `EXPRESS_PARAMETER_LIMIT`. The default is `1000`. The most common use of this is for providing HTTP end points for services with webhooks to push to, and have those show up in chat. ```javascript // .mjs export default async robot => { // the expected value of :room is going to vary by adapter, it might be a numeric id, name, token, or some other value robot.router.post('/hubot/chatsecrets/:room', async (req, res) => { // NOTE: this is the Express route handler, not Hubot's, so `res.send` isn't async/await. const room = req.params.room const data = req.body?.payload ? JSON.parse(req.body.payload) : req.body const secret = data.secret await robot.messageRoom(room, `I have a secret: ${secret}`) res.send('OK') }) } ``` Test it with curl; also see section on [error handling](#error-handling) below. ```sh # raw json, must specify Content-Type: application/json curl -X POST -H "Content-Type: application/json" -d '{"secret":"C-TECH Astronomy"}' http://127.0.0.1:8080/hubot/chatsecrets/general # defaults Content-Type: application/x-www-form-urlencoded, must st payload=... curl -d 'payload=%7B%22secret%22%3A%22C-TECH+Astronomy%22%7D' http://127.0.0.1:8080/hubot/chatsecrets/general ``` All endpoint URLs should start with the literal string `/hubot` (regardless of what your robot's name is). This consistency makes it easier to set up webhooks (copy-pasteable URL) and guarantees that URLs are valid (not all bot names are URL-safe). ## Events Hubot can also respond to events which can be used to pass data between scripts. This is done by encapsulating Node.js's [EventEmitter](http://nodejs.org/api/events.html#events_class_events_eventemitter) with `robot.emit` and `robot.on`. One use case for this would be to have one script for handling interactions with a service, and then emitting events as they come up. For example, we could have a script that receives data from a GitHub post-commit hook, make that emit commits as they come in, and then have another script act on those commits. ```javascript // src/scripts/github-commits.mjs export default async robot => { robot.router.post('/hubot/gh-commits', (req, res) => { robot.emit('commit', { user: {}, //hubot user object repo: 'https://github.com/github/hubot', hash: '2e1951c089bd865839328592ff673d2f08153643' }) }) } ``` ```javascript export default async robot => { robot.on('commit', async (commit) => { await robot.send(commit.user, `Will now deploy ${commit.hash} from ${commit.repo}!`) // deploy code goes here }) } ``` If you provide an event, it's highly recommended to include a hubot user or room object in its data. This would allow for hubot to notify a user or room in chat. ## Error Handling No code is perfect, and errors and exceptions are to be expected. Previously, an uncaught exceptions would crash your hubot instance. Hubot now includes an `uncaughtException` handler, which provides hooks for scripts to do something about exceptions. ```javascript // src/scripts/does-not-compute.mjs export default async robot => { robot.error(async (err, res) => { robot.logger.error('DOES NOT COMPUTE') if(res) { await res.reply('DOES NOT COMPUTE') } } } ``` You can do anything you want here, but you will want to take extra precaution of rescuing and logging errors, particularly with asynchronous code. Otherwise, you might find yourself with recursive errors and not know what is going on. Under the hood, there is an 'error' event emitted, with the error handlers consuming that event. The uncaughtException handler [technically leaves the process in an unknown state](http://nodejs.org/api/process.html#process_event_uncaughtexception). Therefore, you should rescue your own exceptions whenever possible, and emit them yourself. The first parameter is the error emitted, and the second parameter is an optional message that generated the error. Using previous examples: ```javascript robot.router.post()'/hubot/chatsecrets/:room', (req, res) => { const room = req.params.room let data = null try { data = JSON.parse(req.body.payload) } catch(err) { robot.emit('error', err) } // rest of the code here } robot.hear(/midnight train/i, (res) => { robot.http('https://midnight-train') .get()((err, res, body) => { if (err) { res.reply('Had problems taking the midnight train') robot.emit('error', err, res) return } // rest of code here }) }) ``` For the second example, it's worth thinking about what messages the user would see. If you have an error handler that replies to the user, you may not need to add a custom message and could send back the error message provided to the `get()` request, but of course it depends on how public you want to be with your exception reporting. ## Documenting Scripts Hubot scripts can be documented with comments at the top of their file, for example: ```javascript // Description: // // // Dependencies: // "": "" // // Configuration: // LIST_OF_ENV_VARS_TO_SET // // Commands: // hubot - // - // // Notes: // // // Author: // ``` The most important and user facing of these is `Commands`. At load time, Hubot looks at the `Commands` section of each scripts, and build a list of all commands. The [hubot-help](https://github.com/hubotio/hubot-help) script lets a user ask for help across all commands, or with a search. Therefore, documenting the commands make them a lot more discoverable by users. When documenting commands, here are some best practices: * Stay on one line. Help commands get sorted, so would insert the second line at an unexpected location, where it probably won't make sense. * Refer to the Hubot as hubot, even if your hubot is named something else. It will automatically be replaced with the correct name. This makes it easier to share scripts without having to update docs. * For `robot.respond` documentation, always prefix with `hubot`. Hubot will automatically replace this with your robot's name, or the robot's alias if it has one * Check out how man pages document themselves. In particular, brackets indicate optional parts, '...' for any number of parameters, etc. The other sections are more relevant to developers of the bot, particularly dependencies, configuration variables, and notes. All contributions to [hubot-scripts](https://github.com/github/hubot-scripts) should include all these sections that are related to getting up and running with the script. ## Persistence Hubot has two persistence methods available that can be used to store and retrieve data by scripts: an in-memory key-value store exposed as `robot.brain`, and an optional persistent database-backed key-value store expsoed as `robot.datastore`. ### Brain ```javascript robot.respond(/have a soda/i, async res => { // Get number of sodas had (coerced to a number). const sodasHad = robot.brain.get('totalSodas') * 1 ?? 0 if (sodasHad > 4) { await res.reply(`I'm too fizzy..`) } else { await res.reply('Sure!') robot.brain.set('totalSodas', sodasHad + 1) } }) robot.respond(/sleep it off/i, async res => { robot.brain.set('totalSodas', 0) await res.reply('zzzzz') } ``` If the script needs to lookup user data, there are methods on `robot.brain` for looking up one or many users by id, name, or 'fuzzy' matching of name: `userForName`, `userForId`, `userForFuzzyName`, and `usersForFuzzyName`. ```javascript export default async robot => { robot.respond(/who is @?([\w .\-]+)\?*$/i, async res => { const name = res.match[1].trim() const users = robot.brain.usersForFuzzyName(name) if (users.length == 1) { const user = users[0] // Do something interesting here.. } await res.send(`${name} is user - ${user}`) }) } ``` ### Datastore Unlike the brain, the datastore's getter and setter methods are asynchronous and don't resolve until the call to the underlying database has resolved. This requires a slightly different approach to accessing data: ```javascript robot.respond(/have a soda/i, async res => { // Get number of sodas had (coerced to a number). robot.datastore.get('totalSodas').then((value) => { const sodasHad = value * 1 ?? 0 if (sodasHad > 4) { await res.reply(`I'm too fizzy..`) } else { await res.reply('Sure!') robot.brain.set('totalSodas', sodasHad + 1) } }) }) robot.respond(/sleep it off/i, async res => { await robot.datastore.set('totalSodas', 0) await res.reply('zzzzz') }) ``` The datastore also allows setting and getting values which are scoped to individual users: ```javascript export default async robot -> robot.respond(/who is @?([\w .\-]+)\?*$/i, async res => { const name = res.match[1].trim() const users = robot.brain.usersForFuzzyName(name) if (users.length == 1) { const user = users[0] const roles = await user.get('roles') await res.send(`${name} is ${roles.join(', ')}`) } }) ``` ## Script Loading There are three main sources to load scripts from: * all scripts __bundled__ with your hubot installation under `scripts/` directory * __community scripts__ specified in `hubot-scripts.json` and shipped in the `hubot-scripts` npm package * scripts loaded from external __npm packages__ and specified in `external-scripts.json` Scripts loaded from the `scripts/` directory are loaded in alphabetical order, so you can expect a consistent load order of scripts. For example: * `scripts/1-first.js` * `scripts/_second.js` * `scripts/third.js` # Sharing Scripts Once you've built some new scripts to extend the abilities of your robot friend, you should consider sharing them with the world! At the minimum, you need to package up your script and submit it to the [Node.js Package Registry](http://npmjs.org). You should also review the best practices for sharing scripts below. ## See if a script already exists Start by [checking if an NPM package](https://www.npmjs.com) for a script like yours already exists. If you don't see an existing package that you can contribute to, then you can easily get started using `npx hubot --create myhubot`. ## Creating A Script Package Creating a script package for hubot is very simple. Start by running `npx hubot --create myhubot` to create your own instance. `cd myhubot` and create a script. For example, if we wanted to create a hubot script called "my-awesome-script": ```sh % npm hubot --create my-awesome-script % cd my-awesome-script % mkdir src % touch src/AwesomeScript.mjs ``` Open `package.json` and add: ```json "peerDependencies": { "hubot": ">=9" }, ``` If you are using git, the generated directory includes a .gitignore, so you can initialize and add everything: ```sh % git init % git add . % git commit -m "Initial commit" ``` You now have a hubot script repository that's ready to roll! Feel free to crack open `src/AwesomeScript.mjs` and start building up your script! When you've got it ready, you can publish it to [npmjs](http://npmjs.org) by [following their documentation](https://docs.npmjs.com/getting-started/publishing-npm-packages)! You'll probably want to write some unit tests for your new script. Review the [Hubot Repo](https://github.com/hubotio/hubot/tree/main/test for examples creating tests. # Listener Metadata In addition to a regular expression and callback, the `hear` and `respond` functions also accept an optional options Object which can be used to attach arbitrary metadata to the generated Listener object. This metadata allows for easy extension of your script's behavior without modifying the script package. The most important and most common metadata key is `id`. Every Listener should be given a unique name (options.id; defaults to `null`). Names should be scoped by module (e.g. 'my-module.my-listener'). These names allow other scripts to directly address individual listeners and extend them with additional functionality like authorization and rate limiting. Additional extensions may define and handle additional metadata keys. For more information, see the [Listener Middleware section](#listener-middleware). Returning to an earlier example: ```javascript export default async robot => { robot.respond(/annoy me/, id:'annoyance.start', async res => { // code to annoy someone }) robot.respond(/unannoy me/, id:'annoyance.stop', async res => { // code to stop annoying someone }) } ``` These scoped identifiers allow you to externally specify new behaviors like: - authorization policy: "allow everyone in the `annoyers` group to execute `annoyance.*` commands" - rate limiting: "only allow executing `annoyance.start` once every 30 minutes" # Middleware There are three kinds of middleware: Receive, Listener and Response. Receive middleware runs once, before listeners are checked. Listener middleware runs for every listener that matches the message. Response middleware runs for every response sent to a message. ## Execution Process and API Similar to [Express middleware](http://expressjs.com/api.html#middleware), Hubot executes middleware in definition order. Each middleware can either continue the chain (by calling `next`) or interrupt the chain (by calling `done`). If all middleware continues, the listener callback is executed and `done` is called. Middleware may wrap the `done` callback to allow executing code in the second half of the process (after the listener callback has been executed or a deeper piece of middleware has interrupted). Middleware is called with: - `context` - See the each middleware type's API to see what the context will expose. `return true` to allow the message to continue; `return false` to stop it from continuing. Every middleware receives the same API signature of `context`. Different kinds of middleware may receive different information in the `context` object. For more details, see the API for each type of middleware. ### Error Handling Asynchronous middleware should catch its own exceptions, emit an `error` event, and return `true` or `false`. Any uncaught exceptions will interrupt all execution of middleware. ## Listener Middleware Listener middleware inserts logic between the listener matching a message and the listener executing. This allows you to create extensions that run for every matching script. Examples include centralized authorization policies, rate limiting, logging, and metrics. Middleware is implemented like other hubot scripts: instead of using the `hear` and `respond` methods, middleware is registered using `listenerMiddleware`. ## Listener Middleware Examples A fully functioning example can be found in [hubot-rate-limit](https://github.com/michaelansel/hubot-rate-limit/blob/master/src/rate-limit.coffee) (Note, this is a coffee version, non-async/await). A simple example of middleware logging command executions: ```javascript export default async robot => { robot.listenerMiddleware(async context => { // Log commands robot.logger.info(`${context.response.message.user.name} asked me to ${context.response.message.text}`) // Continue executing middleware return true }) } ``` In this example, a log message will be written for each chat message that matches a Listener. A more complex example making a rate limiting decision: ```javascript export default async robot => { // Map of listener ID to last time it was executed let lastExecutedTime = {} robot.listenerMiddleware(async context => { try { // Default to 1s unless listener provides a different minimum period const minPeriodMs = context.listener.options?.rateLimits?.minPeriodMs ?? 1000 // See if command has been executed recently if (lastExecutedTime.hasOwnProperty(context.listener.options.id) && lastExecutedTime[context.listener.options.id] > Date.now() - minPeriodMs) { // Command is being executed too quickly! return false } else { lastExecutedTime[context.listener.options.id] = Date.now() return true } } catch(err) { robot.emit('error', err, context.response) } }) } ``` In this example, the middleware checks to see if the listener has been executed in the last 1,000ms. If it has, the middleware `return false` immediately, preventing the listener callback from being called. If the listener is allowed to execute, the middleware records the time the listener *finished* executing and `return true`. This example also shows how listener-specific metadata can be leveraged to create very powerful extensions: a script developer can use the rate limiting middleware to easily rate limit commands at different rates by just adding the middleware and setting a listener option. ```javascript // .mjs export default async robot => { robot.hear(/hello/, id: 'my-hello', rateLimits: {minPeriodMs: 10000}, async res => { // This will execute no faster than once every ten seconds await res.reply('Why, hello there!') }) } ``` ## Listener Middleware API Listener middleware callbacks receive 1 argument, `context`. Listener middleware context includes these fields: - `listener` - `options`: a simple Object containing options set when defining the listener. See [Listener Metadata](#listener-metadata). - all other properties should be considered internal - `response` - all parts of the standard response API are included in the middleware API. See [Send & Reply](#send--reply). - middleware may decorate (but not modify) the response object with additional information (e.g. add a property to `response.message.user` with a user's LDAP groups) - note: the textual message (`response.message.text`) should be considered immutable in listener middleware # Receive Middleware Receive middleware runs before any listeners have executed. It's suitable for excluded commands that have not been updated to add an ID, metrics, and more. ## Receive Middleware Example This simple middlware bans hubot use by a particular user, including `hear` listeners. If the user attempts to run a command explicitly, it will return an error message. ```javascript const EXCLUDED_USERS = [ '12345' // Restrict access for a user ID for a contractor ] robot.receiveMiddleware(async context => { if (EXCLUDED_USERS.some( id => context.response.message.user.id == id)) { // Don't process this message further. context.response.message.finish() // If the message starts with 'hubot' or the alias pattern, this user was // explicitly trying to run a command, so respond with an error message. if (context.response.message.text?.match(robot.respondPattern(''))) { await context.response.reply(`I'm sorry @${context.response.message.user.name}, but I'm configured to ignore your commands.`) } // Don't process further middleware. return false } else { return true } }) ``` ## Receive Middleware API Receive middleware callbacks receive 1 argument, `context`. Receive middleware context includes these fields: - `response` - this response object will not have a `match` property, as no listeners have been run yet to match it. - middleware may decorate the response object with additional information (e.g. add a property to `response.message.user` with a user's LDAP groups) - middleware may modify the `response.message` object # Response Middleware Response middleware runs against every message hubot sends to a chat room. It's helpful for message formatting, preventing password leaks, metrics, and more. ## Response Middleware Example Response middleware allows you to intercept and modify outgoing messages before they're sent to the chat room. The `context.strings` array contains the actual message text that will be sent. This example changes the format of links from markdown links (like [example](https://example.com)) to the format supported by [Slack](https://slack.com), : ```javascript // .mjs export default async robot => { robot.responseMiddleware(async context => { // Only process plaintext messages (send, reply, etc.) if (!context.plaintext) return true // Modify each string in the response context.strings = context.strings.map(string => { // Convert markdown links to Slack format return string.replace(/\[([^\[\]]*?)\]\((https?:\/\/.*?)\)/, "<$2|$1>") }) return true }) } ``` ## How to Use Response Middleware Response middleware is called every time a message is sent from a listener. Key points: - Access the outgoing message strings via `context.strings` - this is an array of strings being sent - Modify the strings by reassigning `context.strings` or mapping over the array - The `context.method` tells you how the message was sent (`send`, `reply`, `emote`, etc.) - Return `true` to allow the message to continue to the adapter, or `false` to stop it - Be careful not to create infinite loops by sending new messages from middleware, as they will also trigger middleware - Use `context.plaintext` to distinguish between regular messages and other message types ## Response Middleware API Response middleware callbacks receive 1 parameters, `context` and are Promises/async/await. Receive middleware context includes these fields: - `response` - This response object can be used to send new messages from the middleware. Middleware will be called on these new responses. Be careful not to create infinite loops. - `strings` - An array of strings being sent to the chat room adapter. You can edit these, or use `context.strings = ["new strings"]` to replace them. - `method` - A string representing which type of response message the listener sent, such as `send`, `reply`, `emote` or `topic`. - `plaintext` - `true` or `undefined`. This will be set to `true` if the message is of a normal plaintext type, such as `send` or `reply`. This property should be treated as read-only. # Testing Hubot Scripts I use [Node's Test Runner](https://nodejs.org/dist/latest-v20.x/docs/api/test.html) for writing and running tests for Hubot. [package.json](../package.json) ```json "scripts": { "test": "node --test", } ``` ```sh npm t ``` Checkout [Xample.mjs](../test/XampleTest.mjs) for an example that tests the [Xample.mjs](../test/scripts/Xample.mjs) script. In order to isolate your script from Hubot, I've created a [Dummy Adapter](../test/doubles/DummyAdapter.mjs) that you can use when starting a Robot instance to interact with and excercise your code. For now, my suggestion is to copy the `DummyAdapter` into your code so that you can modifiy as your needs evolve. Please feel free to create Github issues if you have questions or comments. I'm happy to collaborate. If you created your bot with `npx hubot --create xample-bot`, then the `DummyAdapter` is already there. Along with an example test. ================================================ FILE: examples/hubot-start.ps1 ================================================ #Hubot PowerShell Start Script #Invoke from the PowerShell prompt or start via automated tools $HubotPath = "drive:\path\to\hubot" $HubotAdapter = "Hubot adapter" Write-Host "Starting Hubot Watcher" While (1) { Write-Host "Starting Hubot" Start-Process powershell -ArgumentList "$HubotPath\bin\hubot –adapter $HubotAdapter" -wait } ================================================ FILE: examples/hubot.service ================================================ ; Hubot systemd service unit file ; Place in e.g. `/etc/systemd/system/hubot.service`, then `systemctl daemon-reload` and `service hubot start`. [Unit] Description=Hubot Requires=network.target After=network.target [Service] Type=simple WorkingDirectory=/path/to/hubot User=change-to-hubot-user Restart=always RestartSec=10 ; Configure Hubot environment variables, use quotes around vars with whitespace as shown below. Environment="HUBOT_aaa=xxx" Environment="HUBOT_bbb='yyy yyy'" ; Alternatively multiple environment variables can loaded from an external file ;EnvironmentFile=/etc/hubot-environment ExecStart=/path/to/hubot/bin/hubot --adapter zzz [Install] WantedBy=multi-user.target ================================================ FILE: index.mjs ================================================ 'use strict' import User from './src/User.mjs' import Brain from './src/Brain.mjs' import Robot from './src/Robot.mjs' import Adapter from './src/Adapter.mjs' import Response from './src/Response.mjs' import Middleware from './src/Middleware.mjs' import { Listener, TextListener } from './src/Listener.mjs' import { TextMessage, EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage, Message } from './src/Message.mjs' import { DataStore, DataStoreUnavailable } from './src/DataStore.mjs' import { CommandBus } from './src/CommandBus.mjs' const loadBot = (adapter, enableHttpd, name, alias) => new Robot(adapter, enableHttpd, name, alias) export { Adapter, User, Brain, Robot, Response, Listener, TextListener, Message, TextMessage, EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage, DataStore, DataStoreUnavailable, Middleware, CommandBus, loadBot } export default { Adapter, User, Brain, Robot, Response, Listener, TextListener, Message, TextMessage, EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage, DataStore, DataStoreUnavailable, Middleware, CommandBus, loadBot } ================================================ FILE: package.json ================================================ { "name": "hubot", "version": "0.0.0-development", "author": "hubot", "keywords": [ "github", "hubot", "campfire", "bot" ], "description": "A simple helpful robot for your Company", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/hubotio/hubot.git" }, "engines": { "node": ">= 18", "npm": ">= 9" }, "main": "./index.mjs", "bin": { "hubot": "./bin/hubot" }, "scripts": { "start": "bin/hubot", "gen": "bin/hubot --create myhubot", "pretest": "node script/simple-lint.mjs", "test": "node --test --test-timeout=40000", "test:smoke": "node src/**/*.js", "test:e2e": "bin/e2e-test.sh", "build:local": "npx @hubot-friends/sfab --folder ./docs --destination ./_site --verbose --serve /hubot/ --watch-path ./docs --scripts ./sfab-hooks", "build": "npx @hubot-friends/sfab --folder ./docs --destination ./_site --verbose --scripts ./sfab-hooks" }, "release": { "branches": [ "main", "next" ], "dryRun": false }, "dependencies": { "express": "^5.2.1", "express-basic-auth": "^1.2.1", "pino": "^10.3.1" } } ================================================ FILE: script/bootstrap ================================================ #!/usr/bin/env bash npm install ================================================ FILE: script/release ================================================ #!/usr/bin/env bash # Tag and push a release. set -e # Make sure we're in the project root. cd $(dirname "$0")/.. # Make sure the darn thing works npm update && script/smoke-test # Make sure we're on the main branch. (git branch | grep -q '* main') || { echo "Only release from the main branch." exit 1 } # Figure out what version we're releasing. tag=v`node -e 'console.log(require("./package.json").version)'` # Ensure there's a line in the CHANGELOG grep "$tag" CHANGELOG.md || { echo "No entry for '$tag' found in the CHANGELOG." exit 1 } # Make sure we haven't released this version before. git fetch -t origin (git tag -l | grep -q "$tag") && { echo "Whoops, there's already a '${tag}' tag." exit 1 } # Tag it and bag it. npm publish && git tag "$tag" && git push origin main --tags ================================================ FILE: script/server ================================================ #!/usr/bin/env bash npm start -- "$@" ================================================ FILE: script/simple-lint.mjs ================================================ import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') const targets = [ 'src', 'test', 'bin', 'configuration', 'sfab-hooks', 'script' ] const ignoreDirs = new Set([ 'node_modules', '.git', '_site', 'docs' ]) const extensions = new Set(['.mjs', '.js']) const issues = [] const isIgnoredDir = (dirName) => ignoreDirs.has(dirName) const walk = async (dir) => { const entries = await fs.readdir(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { if (!isIgnoredDir(entry.name)) { await walk(fullPath) } continue } if (entry.isFile()) { const ext = path.extname(entry.name) if (extensions.has(ext)) { await lintFile(fullPath) } } } } const lintFile = async (filePath) => { const content = await fs.readFile(filePath, 'utf8') const normalizedContent = content.replace(/\r\n/g, '\n') const lines = normalizedContent.split('\n') const hasFinalNewline = normalizedContent.endsWith('\n') lines.forEach((line, index) => { const lineNumber = index + 1 if (line.includes('\t')) { issues.push({ filePath, lineNumber, message: 'tab character found' }) } if (/[ \t]+$/.test(line)) { issues.push({ filePath, lineNumber, message: 'trailing whitespace' }) } }) if (!hasFinalNewline) { issues.push({ filePath, lineNumber: lines.length, message: 'missing final newline' }) } } const run = async () => { for (const target of targets) { const dir = path.join(projectRoot, target) try { const stat = await fs.stat(dir) if (stat.isDirectory()) { await walk(dir) } } catch (error) { if (error?.code !== 'ENOENT') { throw error } } } if (issues.length > 0) { for (const issue of issues) { const relPath = path.relative(projectRoot, issue.filePath) console.error(`${relPath}:${issue.lineNumber} ${issue.message}`) } console.error(`\nFound ${issues.length} lint issue(s)`) process.exitCode = 1 return } console.log('Lint OK') } run().catch((error) => { console.error(error) process.exitCode = 1 }) ================================================ FILE: script/smoke-test ================================================ #!/usr/bin/env bash npm run test:smoke ================================================ FILE: script/test ================================================ #!/usr/bin/env bash npm test -- "$@" ================================================ FILE: sfab-hooks/SfabHook.mjs ================================================ export default () => { return { model (file, model) { return { base: { href: '/hubot/' } } } } } ================================================ FILE: src/Adapter.mjs ================================================ 'use strict' import EventEmitter from 'node:events' class Adapter extends EventEmitter { // An adapter is a specific interface to a chat source for robots. // // robot - A Robot instance. constructor (robot) { super() this.robot = robot } // Public: Raw method for sending data back to the chat source. Extend this. // // envelope - A Object with message, room and user details. // strings - One or more Strings for each message to send. // // Returns results from adapter. async send (envelope, ...strings) {} // Public: Raw method for sending emote data back to the chat source. // Defaults as an alias for send // // envelope - A Object with message, room and user details. // strings - One or more Strings for each message to send. // // Returns results from adapter. async emote (envelope, ...strings) { return this.send(envelope, ...strings) } // Public: Raw method for building a reply and sending it back to the chat // source. Extend this. // // envelope - A Object with message, room and user details. // strings - One or more Strings for each reply to send. // // Returns results from adapter. async reply (envelope, ...strings) {} // Public: Raw method for setting a topic on the chat source. Extend this. // // envelope - A Object with message, room and user details. // strings - One more more Strings to set as the topic. // // Returns results from adapter. async topic (envelope, ...strings) {} // Public: Raw method for playing a sound in the chat source. Extend this. // // envelope - A Object with message, room and user details. // strings - One or more strings for each play message to send. // // Returns results from adapter. async play (envelope, ...strings) {} // Public: Raw method for invoking the bot to run. Extend this. // // Returns whatever the extended adapter returns. async run () {} // Public: Raw method for shutting the bot down. Extend this. // // Returns nothing. close () { this.removeAllListeners() } // Public: Dispatch a received message to the robot. // // Returns nothing. async receive (message) { await this.robot.receive(message) } // Public: Get an Array of User objects stored in the brain. // // Returns an Array of User objects. // @deprecated Use @robot.brain users () { this.robot.logger.warn('@users() is going to be deprecated in 11.0.0 use @robot.brain.users()') return this.robot.brain.users() } // Public: Get a User object given a unique identifier. // // Returns a User instance of the specified user. // @deprecated Use @robot.brain userForId (id, options) { this.robot.logger.warn('@userForId() is going to be deprecated in 11.0.0 use @robot.brain.userForId()') return this.robot.brain.userForId(id, options) } // Public: Get a User object given a name. // // Returns a User instance for the user with the specified name. // @deprecated Use @robot.brain userForName (name) { this.robot.logger.warn('@userForName() is going to be deprecated in 11.0.0 use @robot.brain.userForName()') return this.robot.brain.userForName(name) } // Public: Get all users whose names match fuzzyName. Currently, match // means 'starts with', but this could be extended to match initials, // nicknames, etc. // // Returns an Array of User instances matching the fuzzy name. // @deprecated Use @robot.brain usersForRawFuzzyName (fuzzyName) { this.robot.logger.warn('@userForRawFuzzyName() is going to be deprecated in 11.0.0 use @robot.brain.userForRawFuzzyName()') return this.robot.brain.usersForRawFuzzyName(fuzzyName) } // Public: If fuzzyName is an exact match for a user, returns an array with // just that user. Otherwise, returns an array of all users for which // fuzzyName is a raw fuzzy match (see usersForRawFuzzyName). // // Returns an Array of User instances matching the fuzzy name. // @deprecated Use @robot.brain usersForFuzzyName (fuzzyName) { this.robot.logger.warn('@userForFuzzyName() is going to be deprecated in 11.0.0 use @robot.brain.userForFuzzyName()') return this.robot.brain.usersForFuzzyName(fuzzyName) } // Public: Creates a scoped http client with chainable methods for // modifying the request. This doesn't actually make a request though. // Once your request is assembled, you can call `get()`/`post()`/etc to // send the request. // // Returns a ScopedClient instance. // @deprecated Use node.js fetch. http (url) { this.robot.logger.warn('@http() is going to be deprecated in 11.0.0 use @robot.http()') return this.robot.http(url) } } export default Adapter ================================================ FILE: src/Brain.mjs ================================================ 'use strict' import EventEmitter from 'node:events' import User from './User.mjs' // If necessary, reconstructs a User object. Returns either: // // 1. If the original object was falsy, null // 2. If the original object was a User object, the original object // 3. If the original object was a plain JavaScript object, return // a User object with all of the original object's properties. const reconstructUserIfNecessary = function (user, robot) { if (!user) { return null } if (!user.constructor || (user.constructor && user.constructor.name !== 'User')) { const id = user.id delete user.id // Use the old user as the "options" object, // populating the new user with its values. // Also add the `robot` field so it gets a reference. user.robot = robot const newUser = new User(id, user) delete user.robot return newUser } else { return user } } class Brain extends EventEmitter { // Represents somewhat persistent storage for the robot. Extend this. // // Returns a new Brain with no external storage. constructor (robot) { super() this.data = { users: {}, _private: {} } this.getRobot = function () { return robot } this.autoSave = true robot.on('running', () => { this.resetSaveInterval(5) }) } // Public: Store key-value pair under the private namespace and extend // existing @data before emitting the 'loaded' event. // // Returns the instance for chaining. set (key, value) { let pair if (key === Object(key)) { pair = key } else { pair = {} pair[key] = value } Object.keys(pair).forEach((key) => { this.data._private[key] = pair[key] }) this.emit('loaded', this.data) return this } // Public: Get value by key from the private namespace in @data // or return null if not found. // // Returns the value. get (key) { return this.data._private[key] != null ? this.data._private[key] : null } // Public: Remove value by key from the private namespace in @data // if it exists // // Returns the instance for chaining. remove (key) { if (this.data._private[key] != null) { delete this.data._private[key] } return this } // Public: Emits the 'save' event so that 'brain' scripts can handle // persisting. // // Returns nothing. save () { this.emit('save', this.data) } // Public: Emits the 'close' event so that 'brain' scripts can handle closing. // // Returns nothing. close () { clearInterval(this.saveInterval) this.save() this.emit('close') this.removeAllListeners() } // Public: Enable or disable the automatic saving // // enabled - A boolean whether to autosave or not // // Returns nothing setAutoSave (enabled) { this.autoSave = enabled } // Public: Reset the interval between save function calls. // // seconds - An Integer of seconds between saves. // // Returns nothing. resetSaveInterval (seconds) { if (this.saveInterval) { clearInterval(this.saveInterval) } this.saveInterval = setInterval(() => { if (this.autoSave) { this.save() } }, seconds * 1000) } // Public: Merge keys loaded from a DB against the in memory representation. // // Returns nothing. // // Caveats: Deeply nested structures don't merge well. mergeData (data) { for (const k in data || {}) { this.data[k] = data[k] } // Ensure users in the brain are still User objects. if (data && data.users) { for (const k in data.users) { const user = this.data.users[k] this.data.users[k] = reconstructUserIfNecessary(user, this.getRobot()) } } this.emit('loaded', this.data) } // Public: Get an object of User objects stored in the brain. // // Returns an object of User objects. users () { return this.data.users } // Public: Get a User object given a unique identifier. // // Returns a User instance of the specified user. userForId (id, options) { let user = this.data.users[id] if (!options) { options = {} } options.robot = this.getRobot() if (!user) { user = new User(id, options) this.data.users[id] = user } if (options && options.room && (!user.room || user.room !== options.room)) { user = new User(id, options) this.data.users[id] = user } delete options.robot return user } // Public: Get a User object given a name. // // Returns a User instance for the user with the specified name. userForName (name) { let result = null const lowerName = name.toLowerCase() for (const k in this.data.users || {}) { const userName = this.data.users[k].name if (userName != null && userName.toString().toLowerCase() === lowerName) { result = this.data.users[k] } } return result } // Public: Get all users whose names match fuzzyName. Currently, match // means 'starts with', but this could be extended to match initials, // nicknames, etc. // // Returns an Array of User instances matching the fuzzy name. usersForRawFuzzyName (fuzzyName) { const lowerFuzzyName = fuzzyName.toLowerCase() const users = this.data.users || {} return Object.keys(users).reduce((result, key) => { const user = users[key] if (user.name.toLowerCase().lastIndexOf(lowerFuzzyName, 0) === 0) { result.push(user) } return result }, []) } // Public: If fuzzyName is an exact match for a user, returns an array with // just that user. Otherwise, returns an array of all users for which // fuzzyName is a raw fuzzy match (see usersForRawFuzzyName). // // Returns an Array of User instances matching the fuzzy name. usersForFuzzyName (fuzzyName) { const matchedUsers = this.usersForRawFuzzyName(fuzzyName) const lowerFuzzyName = fuzzyName.toLowerCase() const fuzzyMatchedUsers = matchedUsers.filter(user => user.name.toLowerCase() === lowerFuzzyName) return fuzzyMatchedUsers.length > 0 ? fuzzyMatchedUsers : matchedUsers } } export default Brain ================================================ FILE: src/CommandBus.mjs ================================================ import { EventEmitter } from 'node:events' import fs from 'node:fs' import path from 'node:path' /** * CommandBus provides deterministic command handling for Hubot with safe-by-default behavior. * * Logging Strategy: * - Event logging to disk is disabled by default * - To enable: pass `disableLogging: false` in constructor options * - When enabled, events are written asynchronously (fire-and-forget) to avoid blocking * - Writes happen individually as events occur */ export class CommandBus extends EventEmitter { constructor(robot, options = {}) { super() this.robot = robot this.commands = new Map() this.pendingProposals = new Map() this.typeResolvers = new Map() this.prefix = options.prefix ?? '' this.proposalTTL = options.proposalTTL || 300000 // 5 minutes default this.logPath = options.logPath || path.join(process.cwd(), '.data', 'commands-events.ndjson') this.disableLogging = options.disableLogging ?? true this.permissionProvider = options.permissionProvider || null } register(spec, opts = {}) { if (!spec.id) { throw new Error('Command spec must have an id') } if (!spec.handler || typeof spec.handler !== 'function') { throw new Error('Command spec must have a handler function') } const aliases = this._normalizeAliases(spec.aliases) const existing = this.commands.get(spec.id) if (existing && !opts.update) { throw new Error(`Command ${spec.id} is already registered`) } const command = { id: spec.id, description: spec.description || '', aliases: aliases.original, normalizedAliases: aliases.normalized, examples: spec.examples || [], args: spec.args || {}, sideEffects: spec.sideEffects || [], confirm: spec.confirm || 'if_ambiguous', permissions: spec.permissions || {}, handler: spec.handler } this.commands.set(spec.id, command) const eventPayload = { commandId: spec.id, aliases: command.normalizedAliases, timestamp: Date.now() } if (existing && opts.update) { this.emit('commands:updated', eventPayload) this._log({ event: 'commands:updated', ...eventPayload }) } else { this.emit('commands:registered', eventPayload) this._log({ event: 'commands:registered', ...eventPayload }) } const collisions = this.aliasCollisions() if (Object.keys(collisions).length > 0) { this.emit('commands:alias_collision_detected', { collisions, timestamp: Date.now() }) this._log({ event: 'commands:alias_collision_detected', collisions, timestamp: Date.now() }) } return command } /** * Register a custom type resolver for argument validation. * Resolvers are called during validation and can transform/validate values. * * @param {string} typeName - The type name to register (e.g., 'project_id') * @param {Function} resolver - Async function(value, schema, context) that returns validated value or throws * @throws {Error} If typeName is empty or resolver is not a function * @public * * @example * robot.commands.registerTypeResolver('project_id', async (value, schema, context) => { * if (!value.startsWith('PRJ-')) throw new Error('must start with PRJ-') * return value.toUpperCase() * }) */ registerTypeResolver(typeName, resolver) { if (typeof typeName !== 'string' || !typeName) { throw new Error('Type name must be a non-empty string') } if (typeof resolver !== 'function') { throw new Error('Resolver must be a function') } this.typeResolvers.set(typeName, resolver) } unregister(commandId) { return this.commands.delete(commandId) } getCommand(commandId) { return this.commands.get(commandId) } listCommands(filter = {}) { let commands = Array.from(this.commands.values()) if (filter.prefix) { commands = commands.filter(c => c.id.startsWith(filter.prefix)) } return commands } aliasCollisions() { const collisions = {} const aliasMap = new Map() for (const command of this.commands.values()) { for (const alias of command.normalizedAliases || []) { if (!aliasMap.has(alias)) { aliasMap.set(alias, []) } aliasMap.get(alias).push(command.id) } } for (const [alias, ids] of aliasMap.entries()) { if (ids.length > 1) { collisions[alias] = ids } } return collisions } search(query, opts = {}) { if (!query || typeof query !== 'string') { return [] } const normalizedQuery = this._normalizeAlias(query) const queryTokens = this._tokenizeQuery(normalizedQuery) const results = [] for (const command of this.commands.values()) { const aliasMatches = this._scoreAliases(command, normalizedQuery, queryTokens) const descriptionMatches = this._scoreText(command.description, queryTokens) const exampleMatches = this._scoreExamples(command.examples, queryTokens) const bestAliasScore = aliasMatches.score const bestDescScore = descriptionMatches.score const bestExampleScore = exampleMatches.score const bestScore = Math.max(bestAliasScore, bestDescScore, bestExampleScore) if (bestScore === 0) { continue } let matchedOn = 'description' if (bestAliasScore >= bestDescScore && bestAliasScore >= bestExampleScore) { matchedOn = 'alias' } else if (bestExampleScore >= bestDescScore) { matchedOn = 'example' } results.push({ id: command.id, score: bestScore, matchedOn }) } results.sort((a, b) => b.score - a.score) return results } getHelp(commandId) { const command = this.getCommand(commandId) if (!command) { return null } let help = `Command: ${command.id}\n` help += `Description: ${command.description}\n` help += `Usage: ${this.prefix}${command.id} [options]\n` if (command.aliases.length > 0) { help += `Intent: ${command.aliases.join(', ')}\n` } if (Object.keys(command.args).length > 0) { help += '\nArguments:\n' for (const [name, schema] of Object.entries(command.args)) { const required = schema.required ? ' (required)' : '' const defaultVal = schema.default !== undefined ? ` [default: ${schema.default}]` : '' const values = schema.values ? ` [values: ${schema.values.join(', ')}]` : '' help += ` --${name} (${schema.type})${required}${defaultVal}${values}\n` } } if (command.examples.length > 0) { help += '\nExamples:\n' command.examples.forEach(ex => { help += ` ${ex}\n` }) } return help } parse(text) { if (!text || typeof text !== 'string') { return null } // Strip prefix if present (optional) const withoutPrefix = text.startsWith(this.prefix) ? text.slice(this.prefix.length).trim() : text.trim() const parts = this._tokenize(withoutPrefix) if (parts.length === 0) { return null } const commandId = parts[0] if (!this.commands.has(commandId)) { return null } const command = this.commands.get(commandId) const args = {} for (let i = 1; i < parts.length; i++) { const token = parts[i] // Handle -- key value pattern if (token === '--') { const key = parts[i + 1] const valueToken = parts[i + 2] if (key && valueToken && !valueToken.startsWith('--') && !valueToken.includes(':')) { args[key] = valueToken i += 2 } else if (key) { args[key] = true i += 1 } continue } // Handle --key value or --key "quoted value" if (token.startsWith('--')) { const key = token.slice(2) const nextToken = parts[i + 1] const schema = command.args[key] // Use schema hint: boolean type = flag, others expect value if (schema && schema.type === 'boolean') { args[key] = true } else if (nextToken && !nextToken.startsWith('--') && !nextToken.includes(':')) { args[key] = nextToken i++ // Skip next token } else { // No schema or ambiguous: default to boolean flag args[key] = true } } // Handle key:value or key:"quoted value" else if (token.includes(':')) { const colonIndex = token.indexOf(':') const key = token.slice(0, colonIndex) const value = token.slice(colonIndex + 1) args[key] = value } } const parsed = { commandId, args, rawText: text } this.emit('commands:invocation_parsed', { commandId, args, timestamp: Date.now() }) this._log({ event: 'commands:invocation_parsed', commandId, args, timestamp: Date.now() }) return parsed } _tokenize(text) { const tokens = [] let current = '' let inQuotes = false let quoteChar = null let escapeNext = false for (let i = 0; i < text.length; i++) { const char = text[i] if (escapeNext) { current += char escapeNext = false continue } if (inQuotes && char === '\\') { escapeNext = true continue } if ((char === '"' || char === '\'') && !inQuotes) { inQuotes = true quoteChar = char continue } if (char === quoteChar && inQuotes) { inQuotes = false quoteChar = null continue } if (char === ' ' && !inQuotes) { if (current) { tokens.push(current) current = '' } continue } current += char } if (current) { tokens.push(current) } return tokens } _normalizeAliases(aliases) { if (aliases === undefined || aliases === null) { return { original: [], normalized: [] } } if (!Array.isArray(aliases)) { throw new Error('Command aliases must be an array of strings') } const original = [] const normalized = [] const seen = new Set() for (const alias of aliases) { if (typeof alias !== 'string') { throw new Error('Command aliases must be an array of strings') } const trimmed = alias.trim() if (!trimmed) { throw new Error('Command aliases must be non-empty strings') } const normalizedAlias = this._normalizeAlias(trimmed) if (seen.has(normalizedAlias)) { continue } seen.add(normalizedAlias) original.push(trimmed) normalized.push(normalizedAlias) } return { original, normalized } } _normalizeAlias(alias) { return alias.trim().replace(/\s+/g, ' ').toLowerCase() } _tokenizeQuery(text) { return text.split(/\s+/).filter(Boolean) } _scoreAliases(command, normalizedQuery, queryTokens) { const aliases = command.normalizedAliases || [] if (aliases.length === 0) { return { score: 0 } } if (aliases.includes(normalizedQuery)) { return { score: 100 } } const bestOverlap = aliases.reduce((best, alias) => { const tokens = this._tokenizeQuery(alias) const overlap = queryTokens.filter(t => tokens.includes(t)).length return Math.max(best, overlap) }, 0) return { score: bestOverlap * 10 } } _scoreText(text, queryTokens) { if (!text) { return { score: 0 } } const tokens = this._tokenizeQuery(this._normalizeAlias(text)) const overlap = queryTokens.filter(t => tokens.includes(t)).length return { score: overlap * 5 } } _scoreExamples(examples, queryTokens) { if (!examples || examples.length === 0) { return { score: 0 } } const bestOverlap = examples.reduce((best, example) => { const tokens = this._tokenizeQuery(this._normalizeAlias(example)) const overlap = queryTokens.filter(t => tokens.includes(t)).length return Math.max(best, overlap) }, 0) return { score: bestOverlap * 5 } } async validate(commandId, rawArgs, context) { const command = this.getCommand(commandId) if (!command) { return { ok: false, errors: [`Command ${commandId} not found`], missing: [] } } const args = { ...rawArgs } const errors = [] const missing = [] // Apply defaults and validate each arg for (const [name, schema] of Object.entries(command.args)) { const value = args[name] // Check required if (schema.required && (value === undefined || value === null)) { missing.push(name) continue } // Apply default if (value === undefined && schema.default !== undefined) { args[name] = schema.default continue } // Skip validation if not provided and not required if (value === undefined) { continue } // Type validation and conversion try { args[name] = await this._validateType(name, value, schema, context) } catch (err) { errors.push(err.message) } } if (missing.length > 0 || errors.length > 0) { const result = { ok: false, errors, missing, args } this.emit('commands:validation_failed', { commandId, errors, missing, timestamp: Date.now() }) this._log({ event: 'commands:validation_failed', commandId, errors, missing, timestamp: Date.now() }) return result } return { ok: true, args } } async _validateType(name, value, schema, context) { // Check custom type resolvers first if (this.typeResolvers.has(schema.type)) { const resolver = this.typeResolvers.get(schema.type) try { return await resolver(value, schema, context) } catch (err) { throw new Error(`Argument ${name}: ${err.message}`) } } // Built-in types switch (schema.type) { case 'string': return String(value) case 'number': { const num = Number(value) if (isNaN(num)) { throw new Error(`Argument ${name} must be a number`) } return num } case 'boolean': { return coerceToBoolean(value) } case 'enum': { if (!Array.isArray(schema.values) || schema.values.length === 0) { throw new Error(`Argument ${name}: enum values must be a non-empty array`) } if (!schema.values.includes(value)) { throw new Error(`Argument ${name} must be one of: ${schema.values.join(', ')}`) } return value } case 'user': { const users = this.robot.brain.users() const user = Object.values(users).find(u => u.name === value || u.id === value) if (!user) { throw new Error(`Argument ${name}: user "${value}" not found`) } return user } case 'room': { if (!value.startsWith('#')) { throw new Error(`Argument ${name}: room must start with #`) } return value } case 'date': { let date if (value === 'today') { date = new Date() date.setHours(0, 0, 0, 0) } else if (value === 'tomorrow') { date = new Date() date.setDate(date.getDate() + 1) date.setHours(0, 0, 0, 0) } else { date = new Date(value) } if (isNaN(date.getTime())) { throw new Error(`Argument ${name}: invalid date "${value}"`) } return date } default: return value } } needsConfirmation(commandId) { const command = this.getCommand(commandId) if (!command) { return false } if (command.confirm === 'always') { return true } if (command.confirm === 'never') { return false } // Default: confirm if has side effects return command.sideEffects.length > 0 } async propose(proposal, context) { const { commandId, args } = proposal const command = this.getCommand(commandId) if (!command) { throw new Error(`Command ${commandId} not found`) } const confirmationKey = this._getConfirmationKey(context.user.id, context.room) const preview = this._renderPreview(commandId, args) const pendingProposal = { commandId, args, context, preview, confirmationKey, timestamp: Date.now(), timeoutId: null } this.pendingProposals.set(confirmationKey, pendingProposal) // Set TTL timeout pendingProposal.timeoutId = setTimeout(() => { this.pendingProposals.delete(confirmationKey) }, this.proposalTTL) this.emit('commands:proposal_created', { commandId, confirmationKey, timestamp: Date.now() }) this.emit('commands:proposal_confirm_requested', { commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_created', commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_confirm_requested', commandId, confirmationKey, timestamp: Date.now() }) return pendingProposal } async confirm(replyText, context) { const confirmationKey = this._getConfirmationKey(context.user.id, context.room) const pending = this.pendingProposals.get(confirmationKey) if (!pending) { return null } const normalizedReply = replyText.toLowerCase().trim() if (normalizedReply === 'yes' || normalizedReply === 'y') { clearTimeout(pending.timeoutId) this.pendingProposals.delete(confirmationKey) this.emit('commands:proposal_confirmed', { commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_confirmed', commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) const result = await this.execute(pending.commandId, pending.args, pending.context) return { executed: true, result } } if (normalizedReply === 'no' || normalizedReply === 'n' || normalizedReply === 'cancel') { clearTimeout(pending.timeoutId) this.pendingProposals.delete(confirmationKey) this.emit('commands:proposal_cancelled', { commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_cancelled', commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) return { cancelled: true } } return null } async execute(commandId, args, context) { const command = this.getCommand(commandId) if (!command) { throw new Error(`Command ${commandId} not found`) } // Check permissions if (command.permissions.rooms && command.permissions.rooms.length > 0) { if (!command.permissions.rooms.includes(context.room)) { this.emit('commands:permission_denied', { commandId, room: context.room, timestamp: Date.now() }) this._log({ event: 'commands:permission_denied', commandId, room: context.room, timestamp: Date.now() }) throw new Error('Permission denied: command not allowed in this room') } } if (command.permissions.roles && command.permissions.roles.length > 0) { if (this.permissionProvider && typeof this.permissionProvider.hasRole === 'function') { const allowed = await this.permissionProvider.hasRole(context.user, command.permissions.roles, context) if (!allowed) { this.emit('commands:permission_denied', { commandId, roles: command.permissions.roles, timestamp: Date.now() }) this._log({ event: 'commands:permission_denied', commandId, roles: command.permissions.roles, timestamp: Date.now() }) throw new Error('Permission denied: insufficient role') } } } try { const result = await command.handler({ args, context }) this.emit('commands:executed', { commandId, timestamp: Date.now() }) this._log({ event: 'commands:executed', commandId, timestamp: Date.now() }) return result } catch (err) { this.emit('commands:error', { commandId, error: err.message, timestamp: Date.now() }) this._log({ event: 'commands:error', commandId, error: err.message, timestamp: Date.now() }) throw err } } async invoke(text, context) { const parsed = this.parse(text) if (!parsed) { return null } const helpRequested = parsed.args && (parsed.args.help === true || parsed.args.h === true) if (helpRequested) { const helpText = this.getHelp(parsed.commandId) return { ok: true, helpOnly: true, result: helpText } } const validation = await this.validate(parsed.commandId, parsed.args, context) if (!validation.ok) { return validation } // Check if needs confirmation if (this.needsConfirmation(parsed.commandId)) { const proposal = await this.propose({ commandId: parsed.commandId, args: validation.args }, context) return { needsConfirmation: true, proposal } } const result = await this.execute(parsed.commandId, validation.args, context) return { ok: true, result } } _getConfirmationKey(userId, room) { return `${userId}:${room}` } _renderPreview(commandId, args) { let preview = `${this.prefix}${commandId}` for (const [key, value] of Object.entries(args)) { const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value) const needsQuotes = valueStr.includes(' ') || valueStr.includes('"') const escapedValue = needsQuotes ? valueStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"') : valueStr preview += ` --${key} ${needsQuotes ? `"${escapedValue}"` : escapedValue}` } return preview } /** * Clear all pending proposal timers and proposals. * Call this during shutdown or in test teardown to prevent timers from keeping the process alive. * * @public */ clearPendingProposals() { for (const proposal of this.pendingProposals.values()) { if (proposal.timeoutId) { clearTimeout(proposal.timeoutId) } } this.pendingProposals.clear() } _log(event) { if (this.disableLogging) { return } // Fire and forget - write asynchronously without blocking this._writeLog(event).catch(() => {}) } async _writeLog(event) { try { const logDir = path.dirname(this.logPath) await fs.promises.mkdir(logDir, { recursive: true }) const line = JSON.stringify(event) + '\n' await fs.promises.appendFile(this.logPath, line, 'utf8') } catch (err) { // Silent fail for logging errors } } } function coerceToBoolean(value) { if (typeof value === 'boolean') { return value } if (typeof value === 'number') { return value !== 0 } if (typeof value === 'string') { const normalized = value.trim().toLowerCase() if (['true', 't', 'yes', 'y', '1', 'on'].includes(normalized)) { return true } if (['false', 'f', 'no', 'n', '0', 'off'].includes(normalized)) { return false } } return Boolean(value) } ================================================ FILE: src/DataStore.mjs ================================================ 'use strict' export class DataStore { // Represents a persistent, database-backed storage for the robot. Extend this. // // Returns a new Datastore with no storage. constructor (robot) { this.robot = robot } // Public: Set value for key in the database. Overwrites existing // values if present. Returns a promise which resolves when the // write has completed. // // Value can be any JSON-serializable type. async set (key, value) { return await this._set(key, value, 'global') } // Public: Assuming `key` represents an object in the database, // sets its `objectKey` to `value`. If `key` isn't already // present, it's instantiated as an empty object. async setObject (key, objectKey, value) { const object = await this.get(key) const target = object || {} target[objectKey] = value return await this.set(key, target) } // Public: Adds the supplied value(s) to the end of the existing // array in the database marked by `key`. If `key` isn't already // present, it's instantiated as an empty array. async setArray (key, value) { const object = await this.get(key) const target = object ?? [] // Extend the array if the value is also an array, otherwise // push the single value on the end. if (Array.isArray(value)) { return await this.set(key, target.concat(value)) } else { return await this.set(key, target.concat([value])) } } // Public: Get value by key if in the database or return `undefined` // if not found. Returns a promise which resolves to the // requested value. async get (key) { return await this._get(key, 'global') } // Public: Digs inside the object at `key` for a key named // `objectKey`. If `key` isn't already present, or if it doesn't // contain an `objectKey`, returns `undefined`. async getObject (key, objectKey) { const object = await this.get(key) const target = object || {} return target[objectKey] } // Private: Implements the underlying `set` logic for the datastore. // This will be called by the public methods. This is one of two // methods that must be implemented by subclasses of this class. // `table` represents a unique namespace for this key, such as a // table in a SQL database. // // This returns a resolved promise when the `set` operation is // successful, and a rejected promise if the operation fails. _set (key, value, table) { throw new DataStoreUnavailable('Setter called on the abstract class.') } // Private: Implements the underlying `get` logic for the datastore. // This will be called by the public methods. This is one of two // methods that must be implemented by subclasses of this class. // `table` represents a unique namespace for this key, such as a // table in a SQL database. // // This returns a resolved promise containing the fetched value on // success, and a rejected promise if the operation fails. _get (key, table) { throw new DataStoreUnavailable('Getter called on the abstract class.') } } export class DataStoreUnavailable extends Error {} export default { DataStore, DataStoreUnavailable } ================================================ FILE: src/GenHubot.mjs ================================================ import { spawnSync } from 'node:child_process' import File from 'node:fs' import path from 'node:path' function runCommands (hubotDirectory, options) { options.hubotInstallationPath = options?.hubotInstallationPath ?? 'hubot' console.log('creating hubot directory', hubotDirectory) try { File.mkdirSync(hubotDirectory, { recursive: true }) } catch (error) { console.log(`${hubotDirectory} exists, continuing to the next operation.`) } const envFilePath = path.resolve(process.cwd(), '.env') process.chdir(hubotDirectory) let output = spawnSync('npm', ['init', '-y'], { shell: true, stdio: 'inherit' }) console.log('npm init', output.stderr?.toString() ?? '') if (options.hubotInstallationPath !== 'hubot') { output = spawnSync('npm', ['pack', `${options.hubotInstallationPath}`], { shell: true, stdio: 'inherit' }) console.log('npm pack', output.stderr?.toString() ?? '', output.stdout?.toString() ?? '') const customHubotPackage = JSON.parse(File.readFileSync(`${options.hubotInstallationPath}/package.json`, 'utf8')) output = spawnSync('npm', ['i', `${customHubotPackage.name}-${customHubotPackage.version}.tgz`], { shell: true, stdio: 'inherit' }) console.log(`npm i ${customHubotPackage.name}-${customHubotPackage.version}.tgz`, output.stderr?.toString() ?? '', output.stdout?.toString() ?? '') } else { output = spawnSync('npm', ['i', 'hubot@latest'], { shell: true, stdio: 'inherit' }) } output = spawnSync('npm', ['i', 'hubot-help@latest', 'hubot-rules@latest', 'hubot-diagnostics@latest'].concat([options.adapter]).filter(Boolean)) console.log('npm i', output.stderr?.toString() ?? '', output.stdout?.toString() ?? '') File.mkdirSync(path.join('tests', 'doubles'), { recursive: true }) const externalScriptsPath = path.resolve('./', 'external-scripts.json') if (!File.existsSync(externalScriptsPath)) { File.writeFileSync(externalScriptsPath, '[]') } let escripts = File.readFileSync(externalScriptsPath, 'utf8') if (escripts.length === 0) escripts = '[]' const externalScripts = JSON.parse(escripts) externalScripts.push('hubot-help') externalScripts.push('hubot-rules') externalScripts.push('hubot-diagnostics') File.writeFileSync(externalScriptsPath, JSON.stringify(externalScripts, null, 2)) File.mkdirSync(path.join('scripts'), { recursive: true }) File.writeFileSync('./scripts/Xample.mjs', `// Description: // Test script // // Commands: // hubot helo - Responds with Hello World!. // // Notes: // This is a test script. // export default async (robot) => { robot.respond(/helo$/, async res => { await res.reply("HELO World! I'm Dumbotheelephant.") }) robot.respond(/helo room/, async res => { await res.send('Hello World!') }) robot.router.get('/helo', async (req, res) => { res.send("HELO World! I'm Dumbotheelephant.") }) }`) File.writeFileSync('./tests/doubles/DummyAdapter.mjs', ` 'use strict' import { Adapter, TextMessage } from 'hubot' export class DummyAdapter extends Adapter { constructor (robot) { super(robot) this.name = 'DummyAdapter' this.messages = new Set() } async send (envelope, ...strings) { this.emit('send', envelope, ...strings) this.robot.emit('send', envelope, ...strings) } async reply (envelope, ...strings) { this.emit('reply', envelope, ...strings) this.robot.emit('reply', envelope, ...strings) } async topic (envelope, ...strings) { this.emit('topic', envelope, ...strings) this.robot.emit('topic', envelope, ...strings) } async play (envelope, ...strings) { this.emit('play', envelope, ...strings) this.robot.emit('play', envelope, ...strings) } async run () { // This is required to get the scripts loaded this.emit('connected') } close () { this.emit('closed') } async say (user, message, room) { this.messages.add(message) user.room = room await this.robot.receive(new TextMessage(user, message)) } } export default { async use (robot) { return new DummyAdapter(robot) } } `) File.writeFileSync('./tests/XampleTest.mjs', ` import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { Robot } from 'hubot' // You need a dummy adapter to test scripts import dummyRobot from './doubles/DummyAdapter.mjs' // Mocks Aren't Stubs // https://www.martinfowler.com/articles/mocksArentStubs.html describe('Xample testing Hubot scripts', () => { let robot = null beforeEach(async () => { process.env.EXPRESS_PORT = 0 robot = new Robot(dummyRobot, true, 'Dumbotheelephant') await robot.loadAdapter() await robot.run() await robot.loadFile('./scripts', 'Xample.mjs') }) afterEach(() => { delete process.env.EXPRESS_PORT robot.shutdown() }) it('should handle /helo request', async () => { const expected = "HELO World! I'm Dumbotheelephant." const url = 'http://localhost:' + robot.server.address().port + '/helo' const response = await fetch(url) const actual = await response.text() assert.strictEqual(actual, expected) }) it('should reply with expected message', async () => { const expected = "HELO World! I'm Dumbotheelephant." const user = robot.brain.userForId('test-user', { name: 'test user' }) let actual = '' robot.on('reply', (envelope, ...strings) => { actual = strings.join('') }) await robot.adapter.say(user, '@Dumbotheelephant helo', 'test-room') assert.strictEqual(actual, expected) }) it('should send message to the #general room', async () => { const expected = 'general' const user = robot.brain.userForId('test-user', { name: 'test user' }) let actual = '' robot.on('send', (envelope, ...strings) => { actual = envelope.room }) await robot.adapter.say(user, '@Dumbotheelephant helo room', 'general') assert.strictEqual(actual, expected) }) }) `) const packageJsonPath = path.resolve(process.cwd(), 'package.json') const packageJson = JSON.parse(File.readFileSync(packageJsonPath, 'utf8')) packageJson.scripts = { start: 'hubot', test: 'node --test' } packageJson.description = 'A simple helpful robot for your Company' if (options.adapter) { packageJson.scripts.start += ` --adapter ${options.adapter}` } File.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) console.log('package.json updated successfully.') const hubotEnvFilePath = path.resolve('.env') try { File.accessSync(envFilePath) File.copyFileSync(envFilePath, hubotEnvFilePath) console.log('.env file copied successfully.') const envContent = File.readFileSync(hubotEnvFilePath, 'utf8') const envLines = envContent.split('\n') for (const line of envLines) { const trimmedLine = line.trim() if (trimmedLine && !trimmedLine.startsWith('#')) { const [key, ...values] = trimmedLine.split('=') const value = values.join('=') process.env[key] = value } } } catch (error) { console.log('.env file not found, continuing to the next operation.') } } export default (hubotDirectory, options) => { try { runCommands(hubotDirectory, options) } catch (error) { console.error('An error occurred:', error) } } ================================================ FILE: src/HttpClient.mjs ================================================ /* Copyright (c) 2014 rick Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* April 15, 2023 Reasoning: ScopedHttpClient is no longer maintained. Decision: Implement a phased approach to deprecate `robot.http` all together in favor of `fetch`. 1. Convert ScopedHttpClient to Javascript and include the module in this repo 2. Add a deprecation warning to `robot.http` 3. Remove `robot.http` in a future release */ import path from 'node:path' import http from 'node:http' import https from 'node:https' import qs from 'node:querystring' const nonPassThroughOptions = [ 'headers', 'hostname', 'encoding', 'auth', 'port', 'protocol', 'agent', 'httpAgent', 'httpsAgent', 'query', 'host', 'path', 'pathname', 'slashes', 'hash' ] class ScopedClient { constructor (url, options) { this.options = this.buildOptions(url, options) this.passthroughOptions = reduce(extend({}, this.options), nonPassThroughOptions) } request (method, reqBody, callback) { let req if (typeof (reqBody) === 'function') { callback = reqBody reqBody = null } try { let requestModule const headers = extend({}, this.options.headers) const sendingData = reqBody && (reqBody.length > 0) headers.Host = this.options.hostname if (this.options.port) { headers.Host += `:${this.options.port}` } // If `callback` is `undefined` it means the caller isn't going to stream // the body of the request using `callback` and we can set the // content-length header ourselves. // // There is no way to conveniently assert in an else clause because the // transfer encoding could be chunked or using a newer framing mechanism. // And this is why we should'nt be creating a wrapper around http. // Please just use `fetch`. if (callback === undefined) { headers['Content-Length'] = sendingData ? Buffer.byteLength(reqBody, this.options.encoding) : 0 } if (this.options.auth) { headers.Authorization = 'Basic ' + Buffer.from(this.options.auth, 'base64') } const port = this.options.port || ScopedClient.defaultPort[this.options.protocol] || 80 let { agent } = this.options if (this.options.protocol === 'https:') { requestModule = https if (this.options.httpsAgent) { agent = this.options.httpsAgent } } else { requestModule = http if (this.options.httpAgent) { agent = this.options.httpAgent } } const requestOptions = { port, host: this.options.hostname, method, path: this.fullPath(), headers, agent } // Extends the previous request options with all remaining options extend(requestOptions, this.passthroughOptions) req = requestModule.request(requestOptions) if (this.options.timeout) { req.setTimeout(this.options.timeout, () => req.abort()) } if (callback) { req.on('error', callback) } if (sendingData) { req.write(reqBody, this.options.encoding) } if (callback) { callback(null, req) } } catch (err) { if (callback) { callback(err, req) } } return callback => { if (callback) { req.on('response', res => { res.setEncoding(this.options.encoding) let body = '' res.on('data', chunk => { body += chunk }) return res.on('end', () => callback(null, res, body)) }) req.on('error', error => callback(error, null, null)) } req.end() return this } } // Adds the query string to the path. fullPath (p) { const search = qs.stringify(this.options.query) let full = this.join(p) if (search.length > 0) { full += `?${search}` } return full } scope (url, options, callback) { const override = this.buildOptions(url, options) const scoped = new ScopedClient(this.options) .protocol(override.protocol) .host(override.hostname) .path(override.pathname) if (typeof (url) === 'function') { callback = url } else if (typeof (options) === 'function') { callback = options } if (callback) { callback(scoped) } return scoped } join (suffix) { const p = this.options.pathname || '/' if (suffix && (suffix.length > 0)) { if (suffix.match(/^\//)) { return suffix } else { return path.join(p, suffix) } } else { return p } } path (p) { this.options.pathname = this.join(p) return this } query (key, value) { if (!this.options.query) { this.options.query = {} } if (typeof (key) === 'string') { if (value) { this.options.query[key] = value } else { delete this.options.query[key] } } else { extend(this.options.query, key) } return this } host (h) { if (h && (h.length > 0)) { this.options.hostname = h } return this } port (p) { if (p && ((typeof (p) === 'number') || (p.length > 0))) { this.options.port = p } return this } protocol (p) { if (p && (p.length > 0)) { this.options.protocol = p } return this } encoding (e) { if (e == null) { e = 'utf-8' } this.options.encoding = e return this } timeout (time) { this.options.timeout = time return this } auth (user, pass) { if (!user) { this.options.auth = null } else if (!pass && user.match(/:/)) { this.options.auth = user } else { this.options.auth = `${user}:${pass}` } return this } header (name, value) { this.options.headers[name] = value return this } headers (h) { extend(this.options.headers, h) return this } buildOptions () { const options = {} let i = 0 while (arguments[i]) { const ty = typeof arguments[i] if (ty === 'string') { const parsedUrl = new URL(arguments[i]) const query = {} parsedUrl.searchParams.forEach((v, k) => { query[k] = v }) extend(options, { href: parsedUrl.href, origin: parsedUrl.origin, protocol: parsedUrl.protocol, username: parsedUrl.username, password: parsedUrl.password, host: parsedUrl.host, hostname: parsedUrl.hostname, port: parsedUrl.port, pathname: parsedUrl.pathname, search: parsedUrl.search, searchParams: parsedUrl.searchParams, query, hash: parsedUrl.hash }) delete options.url delete options.href delete options.search } else if (ty !== 'function') { extend(options, arguments[i]) } i += 1 } if (!options.headers) { options.headers = {} } if (options.encoding == null) { options.encoding = 'utf-8' } return options } } ScopedClient.methods = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD'] ScopedClient.methods.forEach(method => { ScopedClient.prototype[method.toLowerCase()] = function (body, callback) { return this.request(method, body, callback) } }) ScopedClient.prototype.del = ScopedClient.prototype.delete ScopedClient.defaultPort = { 'http:': 80, 'https:': 443, http: 80, https: 443 } const extend = function (a, b) { Object.keys(b).forEach(prop => { a[prop] = b[prop] }) return a } // Removes keys specified in second parameter from first parameter const reduce = function (a, b) { for (const propName of Array.from(b)) { delete a[propName] } return a } export default { create (url, options) { return new ScopedClient(url, options) } } ================================================ FILE: src/Listener.mjs ================================================ 'use strict' import { inspect } from 'node:util' import Middleware from './Middleware.mjs' class Listener { // Listeners receive every message from the chat source and decide if they // want to act on it. // An identifier should be provided in the options parameter to uniquely // identify the listener (options.id). // // robot - A Robot instance. // matcher - A Function that determines if this listener should trigger the // callback. // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is triggered if the incoming message matches. constructor (robot, matcher, options, callback) { this.robot = robot this.matcher = matcher this.options = options ?? {} this.callback = callback if (this.matcher == null) { throw new Error('Missing a matcher for Listener') } if (!this.callback) { this.callback = this.options this.options = {} } if (!this.options?.id) { this.options.id = null } if (this.callback == null || typeof this.callback !== 'function') { throw new Error('Missing a callback for Listener') } } // Public: Determines if the listener likes the content of the message. If // so, a Response built from the given Message is passed through all // registered middleware and potentially the Listener callback. Note that // middleware can intercept the message and prevent the callback from ever // being executed. // // message - A Message instance. // middleware - Optional Middleware object to execute before the Listener callback // // Returns the result of the callback. async call (message, middleware) { if (middleware && typeof middleware === 'function') { const fn = middleware middleware = new Middleware(this.robot) middleware.register(fn) } if (!middleware) { middleware = new Middleware(this.robot) } const match = this.matcher(message) if (!match) return null if (this.regex) { this.robot.logger.debug(`Message '${message}' matched regex /${inspect(this.regex)}/; listener.options = ${inspect(this.options)}`) } const response = new this.robot.Response(this.robot, message, match) try { const shouldContinue = await middleware.execute({ listener: this, response }) if (shouldContinue === false) return null } catch (e) { this.robot.logger.error(`Error executing middleware for listener: ${e.stack}`) } try { return await this.callback(response) } catch (e) { this.robot.logger.error(`Error executing listener callback: ${e.stack}`) this.robot.emit('error', e, response) } return null } } class TextListener extends Listener { // TextListeners receive every message from the chat source and decide if they // want to act on it. // // robot - A Robot instance. // regex - A Regex that determines if this listener should trigger the // callback. // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is triggered if the incoming message matches. constructor (robot, regex, options, callback) { function matcher (message) { if (typeof message.match === 'function') { return message.match(regex) } } super(robot, matcher, options, callback) this.regex = regex } } export { Listener, TextListener } ================================================ FILE: src/Message.mjs ================================================ 'use strict' export class Message { // Represents an incoming message from the chat. // // user - A User instance that sent the message. // done - A boolean indicating if the message has been handled. constructor (user, done) { this.user = user this.done = done || false this.room = this.user?.room } // Indicates that no other Listener should be called on this object // // Returns nothing. finish () { this.done = true } } export class TextMessage extends Message { // Represents an incoming message from the chat. // // user - A User instance that sent the message. // text - A String message. // id - A String of the message ID. constructor (user, text, id) { super(user) this.text = text this.id = id } // Determines if the message matches the given regex. // // regex - A Regex to check. // // Returns a Match object or null. match (regex) { return this.text.match(regex) } // String representation of a TextMessage // // Returns the message text toString () { return this.text } } // Represents an incoming user entrance notification. // // user - A User instance for the user who entered. export class EnterMessage extends Message {} // Represents an incoming user exit notification. // // user - A User instance for the user who left. export class LeaveMessage extends Message {} // Represents an incoming topic change notification. // // user - A User instance for the user who changed the topic. // text - A String of the new topic // id - A String of the message ID. export class TopicMessage extends TextMessage {} // Represents a catch all error message. // // user - A User instance that sent the message. // message - A TextMessage with the message. export class CatchAllMessage extends Message { // Represents a message that no matchers matched. // // message - The original message. constructor (message) { super(message.user) this.message = message } } export default { Message, TextMessage, EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage } ================================================ FILE: src/Middleware.mjs ================================================ 'use strict' class Middleware { constructor (robot) { this.robot = robot this.stack = [] } // Public: Execute all middleware in order and call 'next' with the latest // 'done' callback if last middleware calls through. If all middleware is // compliant, 'done' should be called with no arguments when the entire // round trip is complete. // // context - context object that is passed through the middleware stack. // When handling errors, this is assumed to have a `response` property. // // Returns bool, true | false, whether or not to continue execution async execute (context) { let shouldContinue = true for await (const middleware of this.stack) { try { shouldContinue = await middleware(context) if (shouldContinue === false) break } catch (e) { this.robot.emit('error', e, context.response) break } } return shouldContinue } // Public: Registers new middleware // // middleware - Middleware function to execute prior to the listener callback. Return false to prevent execution of the listener callback. // // Returns nothing. register (middleware) { if (middleware.length !== 1) { throw new Error(`Incorrect number of arguments for middleware callback (expected 1, got ${middleware.length})`) } this.stack.push(middleware) } } export default Middleware ================================================ FILE: src/OptParse.mjs ================================================ import EventEmitter from 'node:events' class OptParse extends EventEmitter { constructor (switches) { super() this.switches = switches } mappings (switches) { const mappings = switches.reduce((acc, current) => { acc[current[0].replace('-', '')] = current[1].split(' ')[0].replace('--', '') return acc }, {}) return mappings } parse (args) { const mappings = this.mappings(this.switches) const options = {} for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg.startsWith('-')) { const cliArg = arg.replace(/^-+/, '') let propertyName = mappings[cliArg] if (!propertyName) { propertyName = Object.values(mappings).find(value => value === cliArg) } const nameToEmit = propertyName propertyName = propertyName.replace(/-([a-z])/g, g => g[1].toUpperCase()) const nextArg = args[i + 1] if (nextArg && !nextArg.startsWith('-')) { options[propertyName] = nextArg i++ } else { options[propertyName] = true } this.emit(nameToEmit, propertyName, nextArg) } } return options } toString () { return `${this.banner} ${this.switches.map(([key, description]) => ` ${key}, ${description}`).join('\n')}` } } export default OptParse ================================================ FILE: src/Response.mjs ================================================ 'use strict' class Response { // Public: Responses are sent to matching listeners. Messages know about the // content and user that made the original message, and how to reply back to // them. // // robot - A Robot instance. // message - A Message instance. // match - A Match object from the successful Regex match. constructor (robot, message, match) { this.robot = robot this.message = message this.match = match this.envelope = { room: this.message.room, user: this.message.user, message: this.message } } // Public: Posts a message back to the chat source // // strings - One or more strings to be posted. The order of these strings // should be kept intact. // // Returns result from middleware. async send (...strings) { return await this.#runWithMiddleware('send', { plaintext: true }, ...strings) } // Public: Posts an emote back to the chat source // // strings - One or more strings to be posted. The order of these strings // should be kept intact. // // Returns result from middleware. async emote (...strings) { return await this.#runWithMiddleware('emote', { plaintext: true }, ...strings) } // Public: Posts a message mentioning the current user. // // strings - One or more strings to be posted. The order of these strings // should be kept intact. // // Returns result from middleware. async reply (...strings) { return await this.#runWithMiddleware('reply', { plaintext: true }, ...strings) } // Public: Posts a topic changing message // // strings - One or more strings to set as the topic of the // room the bot is in. // // Returns result from middleware. async topic (...strings) { return await this.#runWithMiddleware('topic', { plaintext: true }, ...strings) } // Public: Play a sound in the chat source // // strings - One or more strings to be posted as sounds to play. The order of // these strings should be kept intact. // // Returns result from middleware. async play (...strings) { return await this.#runWithMiddleware('play', {}, ...strings) } // Public: Posts a message in an unlogged room // // strings - One or more strings to be posted. The order of these strings // should be kept intact. // // Returns result from middleware. async locked (...strings) { await this.#runWithMiddleware('locked', { plaintext: true }, ...strings) } // Call with a method for the given strings using response // middleware. async #runWithMiddleware (methodName, opts, ...strings) { const context = { response: this, strings, method: methodName } if (opts.plaintext != null) { context.plaintext = true } const shouldContinue = await this.robot.middleware.response.execute(context) if (shouldContinue === false) return return await this.robot.adapter[methodName](this.envelope, ...context.strings) } // Public: Picks a random item from the given items. // // items - An Array of items. // // Returns a random item. random (items) { return items[Math.floor(Math.random() * items.length)] } // Public: Tell the message to stop dispatching to listeners // // Returns nothing. finish () { this.message.finish() } // Public: Creates a scoped http client with chainable methods for // modifying the request. This doesn't actually make a request though. // Once your request is assembled, you can call `get()`/`post()`/etc to // send the request. // // Returns a ScopedClient instance. http (url, options) { return this.robot.http(url, options) } } export default Response ================================================ FILE: src/Robot.mjs ================================================ 'use strict' import EventEmitter from 'node:events' import fs from 'node:fs' import path from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import pino from 'pino' import HttpClient from './HttpClient.mjs' import Brain from './Brain.mjs' import Response from './Response.mjs' import { Listener, TextListener } from './Listener.mjs' import Message from './Message.mjs' import Middleware from './Middleware.mjs' import { CommandBus } from './CommandBus.mjs' const File = fs.promises const HUBOT_DEFAULT_ADAPTERS = ['Campfire', 'Shell'] const HUBOT_DOCUMENTATION_SECTIONS = ['description', 'dependencies', 'configuration', 'commands', 'notes', 'author', 'authors', 'examples', 'tags', 'urls'] const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) class Robot { // Robots receive messages from a chat source (Campfire, irc, etc), and // dispatch them to matching listeners. // // adapter - A String of the adapter name. // httpd - A Boolean whether to enable the HTTP daemon. // name - A String of the robot name, defaults to Hubot. // alias - A String of the alias of the robot name constructor (adapter, httpd, name, alias) { if (name == null) { name = 'Hubot' } if (alias == null) { alias = false } this.name = name this.events = new EventEmitter() this.brain = new Brain(this) this.alias = alias this.adapter = null this.adapterName = 'Shell' if (adapter && typeof (adapter) === 'object') { this.adapter = adapter this.adapterName = adapter.name ?? adapter.constructor.name } else { this.adapterName = adapter ?? this.adapterName } this.shouldEnableHttpd = httpd ?? true this.datastore = null this.Response = Response this.commands = new CommandBus(this) this.helpStrings = [] this.listeners = [] this.middleware = { listener: new Middleware(this), response: new Middleware(this), receive: new Middleware(this) } this.logger = pino({ name, level: process.env.HUBOT_LOG_LEVEL || 'info' }) this.pingIntervalId = null this.globalHttpOptions = {} this.parseVersion() this.errorHandlers = [] this.on('error', (err, res) => { return this.invokeErrorHandlers(err, res) }) this.on('listening', this.herokuKeepalive.bind(this)) // Register built-in help command this.registerHelpCommand() } // Private: Register the built-in help command registerHelpCommand() { this.commands.register({ id: 'help', description: 'Show available commands or search for specific commands', aliases: ['commands', 'list commands', 'show commands'], args: { query: { type: 'string', required: false } }, confirm: 'never', examples: [ 'help', 'help tickets', 'help search "create ticket"' ], handler: async (ctx) => { const { query } = ctx.args // Search mode: use search() API if (query && query.startsWith('search ')) { const searchQuery = query.slice(7).trim() const results = this.commands.search(searchQuery) if (results.length === 0) { return `No commands found matching "${searchQuery}"` } let response = `Commands matching "${searchQuery}":\n\n` results.slice(0, 5).forEach(result => { const cmd = this.commands.getCommand(result.id) response += `• ${cmd.id} - ${cmd.description} (matched: ${result.matchedOn}, score: ${result.score})\n` }) if (results.length > 5) { response += `\n...and ${results.length - 5} more` } return response } // Prefix filter mode if (query) { const commands = this.commands.listCommands({ prefix: query }) if (commands.length === 0) { return `No commands found starting with "${query}"` } let response = `Commands starting with "${query}":\n\n` commands.forEach(cmd => { response += `• ${cmd.id} - ${cmd.description}\n` if (cmd.aliases.length > 0) { response += ` Intent: ${cmd.aliases.join(', ')}\n` } }) return response } // List all commands const commands = this.commands.listCommands() if (commands.length === 0) { return 'No commands registered' } // Group by prefix const grouped = commands.reduce((acc, cmd) => { const prefix = cmd.id.split('.')[0] if (!acc[prefix]) acc[prefix] = [] acc[prefix].push(cmd) return acc }, {}) let response = 'Available commands:\n\n' Object.keys(grouped).sort().forEach(prefix => { response += `${prefix}:\n` grouped[prefix].forEach(cmd => { response += ` • ${cmd.id} - ${cmd.description}\n` }) response += '\n' }) response += 'Usage:\n' response += ' @' + this.name + ' help - Show commands with prefix\n' response += ' @' + this.name + ' help search - Search commands\n' response += ' @' + this.name + ' --help - Show command details' return response } }) } // Public: Adds a custom Listener with the provided matcher, options, and // callback // // matcher - A Function that determines whether to call the callback. // Expected to return a truthy value if the callback should be // executed. // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object if the // matcher function returns true. // // Returns nothing. listen (matcher, options, callback) { this.listeners.push(new Listener(this, matcher, options, callback)) } // Public: Adds a Listener that attempts to match incoming messages based on // a Regex. // // regex - A Regex that determines if the callback should be called. // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object. // // Returns nothing. hear (regex, options, callback) { this.listeners.push(new TextListener(this, regex, options, callback)) } // Public: Adds a Listener that attempts to match incoming messages directed // at the robot based on a Regex. All regexes treat patterns like they begin // with a '^' // // regex - A Regex that determines if the callback should be called. // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object. // // Returns nothing. respond (regex, options, callback) { this.hear(this.respondPattern(regex), options, callback) } // Public: Build a regular expression that matches messages addressed // directly to the robot // // regex - A RegExp for the message part that follows the robot's name/alias // // Returns RegExp. respondPattern (regex) { const regexWithoutModifiers = regex.toString().split('/') regexWithoutModifiers.shift() const modifiers = regexWithoutModifiers.pop() const regexStartsWithAnchor = regexWithoutModifiers[0] && regexWithoutModifiers[0][0] === '^' const pattern = regexWithoutModifiers.join('/') const name = this.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') if (regexStartsWithAnchor) { this.logger.warn('Anchors don’t work well with respond, perhaps you want to use \'hear\'') this.logger.warn(`The regex in question was ${regex.toString()}`) } if (!this.alias) { return new RegExp('^\\s*[@]?' + name + '[:,]?\\s*(?:' + pattern + ')', modifiers) } const alias = this.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') // matches properly when alias is substring of name if (name.length > alias.length) { return new RegExp('^\\s*[@]?(?:' + name + '[:,]?|' + alias + '[:,]?)\\s*(?:' + pattern + ')', modifiers) } // matches properly when name is substring of alias return new RegExp('^\\s*[@]?(?:' + alias + '[:,]?|' + name + '[:,]?)\\s*(?:' + pattern + ')', modifiers) } // Public: Adds a Listener that triggers when anyone enters the room. // // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object. // // Returns nothing. enter (options, callback) { this.listen(msg => msg instanceof Message.EnterMessage, options, callback) } // Public: Adds a Listener that triggers when anyone leaves the room. // // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object. // // Returns nothing. leave (options, callback) { this.listen(msg => msg instanceof Message.LeaveMessage, options, callback) } // Public: Adds a Listener that triggers when anyone changes the topic. // // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object. // // Returns nothing. topic (options, callback) { this.listen(msg => msg instanceof Message.TopicMessage, options, callback) } // Public: Adds an error handler when an uncaught exception or user emitted // error event occurs. // // callback - A Function that is called with the error object. // // Returns nothing. error (callback) { this.errorHandlers.push(callback) } // Calls and passes any registered error handlers for unhandled exceptions or // user emitted error events. // // err - An Error object. // res - An optional Response object that generated the error // // Returns nothing. invokeErrorHandlers (error, res) { this.logger.error(error.stack) this.errorHandlers.forEach((errorHandler) => { try { errorHandler(error, res) } catch (errorHandlerError) { this.logger.error(`while invoking error handler: ${errorHandlerError}\n${errorHandlerError.stack}`) } }) } // Public: Adds a Listener that triggers when no other text matchers match. // // options - An Object of additional parameters keyed on extension name // (optional). // callback - A Function that is called with a Response object. // // Returns nothing. catchAll (options, callback) { // `options` is optional; need to isolate the real callback before // wrapping it with logic below if (callback == null) { callback = options options = {} } this.listen(isCatchAllMessage, options, async msg => { await callback(msg) }) } // Public: Registers new middleware for execution after matching but before // Listener callbacks // // middleware - A function that determines whether or not a given matching // Listener should be executed. The function is called with // (context). If execution should, the middleware should return // true. If not, the middleware should return false. // // Returns nothing. listenerMiddleware (middleware) { this.middleware.listener.register(middleware) } // Public: Registers new middleware for execution as a response to any // message is being sent. // // middleware - A function that examines an outgoing message and can modify // it or prevent its sending. The function is called with // (context). If execution should continue, return true // otherwise return false to stop. To modify the // outgoing message, set context.string to a new message. // // Returns nothing. responseMiddleware (middleware) { this.middleware.response.register(middleware) } // Public: Registers new middleware for execution before matching // // middleware - A function that determines whether or not listeners should be // checked. The function is called with (context). If execution // should continue to the next // middleware or matching phase, it should return true or nothing // otherwise return false to stop. // // Returns nothing. receiveMiddleware (middleware) { this.middleware.receive.register(middleware) } // Public: Passes the given message to any interested Listeners after running // receive middleware. // // message - A Message instance. Listeners can flag this message as 'done' to // prevent further execution. // // Returns array of results from listeners. async receive (message) { const context = { response: new Response(this, message) } const shouldContinue = await this.middleware.receive.execute(context) if (shouldContinue === false) return null return await this.processListeners(context) } // Private: Passes the given message to any interested Listeners. // // message - A Message instance. Listeners can flag this message as 'done' to // prevent further execution. // // Returns array of results from listeners. async processListeners (context) { // Try executing all registered Listeners in order of registration // and return after message is done being processed const results = [] let anyListenersExecuted = false for await (const listener of this.listeners) { try { const match = listener.matcher(context.response.message) if (!match) { continue } const result = await listener.call(context.response.message, this.middleware.listener) results.push(result) anyListenersExecuted = true } catch (err) { this.emit('error', err, context) } if (context.response.message.done) { break } } if (!isCatchAllMessage(context.response.message) && !anyListenersExecuted) { this.logger.debug('No listeners executed; falling back to catch-all') try { const result = await this.receive(new Message.CatchAllMessage(context.response.message)) results.push(result) } catch (err) { this.emit('error', err, context) } } return results } async loadmjs (filePath) { const forImport = this.prepareForImport(filePath) const script = await import(forImport) let result = null if (typeof script?.default === 'function') { result = await script.default(this) } else { this.logger.warn(`Expected ${filePath} (after preparing for import ${forImport}) to assign a function to export default, got ${typeof script}`) } return result } async loadts (filePath) { return this.loadmjs(filePath) } async loadjs (filePath) { const forImport = this.prepareForImport(filePath) const script = (await import(forImport)).default let result = null if (typeof script === 'function') { result = await script(this) } else { this.logger.warn(`Expected ${filePath} (after preparing for import ${forImport}) to assign a function to module.exports, got ${typeof script}`) } return result } // Public: Loads a file in path. // // filepath - A String path on the filesystem. // filename - A String filename in path on the filesystem. // // Returns nothing. async loadFile (filepath, filename) { const ext = path.extname(filename)?.replace('.', '') const full = path.join(filepath, path.basename(filename)) // see https://github.com/hubotio/hubot/issues/1355 if (['js', 'mjs', 'ts'].indexOf(ext) === -1) { this.logger.debug(`Skipping unsupported file type ${full}`) return null } let result = null try { result = await this[`load${ext}`](full) this.parseHelp(full) } catch (error) { this.logger.error(`Unable to load ${full}: ${error.stack}`) throw error } return result } // Public: Loads every script in the given path. // // path - A String path on the filesystem. // // Returns nothing. async load (path) { this.logger.debug(`Loading scripts from ${path}`) const results = [] try { const folder = await File.readdir(path, { withFileTypes: true }) for await (const file of folder) { if (file.isDirectory()) continue try { const result = await this.loadFile(path, file.name) results.push(result) } catch (e) { this.logger.error(`Error loading file ${file.name} - ${e.stack}`) } } } catch (e) { this.logger.error(`Path ${path} does not exist`) } return results } // Public: Load scripts from packages specified in the // `external-scripts.json` file. // // packages - An Array of packages containing hubot scripts to load. // // Returns nothing. async loadExternalScripts (packages) { this.logger.debug('Loading external-scripts from npm packages') try { if (Array.isArray(packages)) { for await (const pkg of packages) { (await import(pkg)).default(this) } return } for await (const key of Object.keys(packages)) { (await import(key)).default(this, packages[key]) } } catch (error) { this.logger.error(`Error loading scripts from npm package - ${error.stack}`) throw error } } // Setup the Express server's defaults. // // Returns Server. async setupExpress () { const user = process.env.EXPRESS_USER const pass = process.env.EXPRESS_PASSWORD const stat = process.env.EXPRESS_STATIC const port = process.env.EXPRESS_PORT || process.env.PORT || 8080 const address = process.env.EXPRESS_BIND_ADDRESS || process.env.BIND_ADDRESS || '0.0.0.0' const limit = process.env.EXPRESS_LIMIT || '100kb' const paramLimit = parseInt(process.env.EXPRESS_PARAMETER_LIMIT) || 1000 const express = (await import('express')).default const basicAuth = (await import('express-basic-auth')).default const app = express() app.use((req, res, next) => { res.setHeader('X-Powered-By', `hubot/${encodeURI(this.name)}`) return next() }) if (user && pass) { const authUser = {} authUser[user] = pass app.use(basicAuth({ users: authUser })) } app.use(express.json({ limit })) app.use(express.urlencoded({ limit, parameterLimit: paramLimit, extended: true })) if (stat) { app.use(express.static(stat)) } return new Promise((resolve, reject) => { try { this.server = app.listen(port, address, () => { this.router = app this.emit('listening', this.server) resolve(this.server) }) } catch (err) { reject(err) } }) } // Setup an empty router object // // returns nothing setupNullRouter () { const msg = 'A script has tried registering a HTTP route while the HTTP server is disabled with --disabled-httpd.' const self = this this.router = { get: () => self.logger.info(msg), post: () => self.logger.info(msg), put: () => self.logger.info(msg), delete: () => self.logger.info(msg) } } // Load the adapter Hubot is going to use. // // path - A String of the path to adapter if local. // adapter - A String of the adapter name to use. // // Returns nothing. async loadAdapter (adapterPath = null) { if (this.adapter && this.adapter.use) { this.adapter = await this.adapter.use(this) this.adapterName = this.adapter.name ?? this.adapter.constructor.name return } this.logger.debug(`Loading adapter ${adapterPath ?? 'from npmjs:'} ${this.adapterName}`) const ext = path.extname(adapterPath ?? '') try { if (Array.from(HUBOT_DEFAULT_ADAPTERS).indexOf(this.adapterName) > -1) { this.adapter = await this.requireAdapterFrom(path.resolve(path.join(__dirname, 'adapters', `${this.adapterName}.mjs`))) } else if (['.js', '.cjs'].includes(ext)) { this.adapter = await this.requireAdapterFrom(path.resolve(adapterPath)) } else if (['.mjs'].includes(ext)) { this.adapter = await this.importAdapterFrom(path.resolve(adapterPath)) } else { this.adapter = await this.importFromRepo(this.adapterName) } } catch (error) { this.logger.error(`Cannot load adapter ${adapterPath ?? '[no path set]'} ${this.adapterName} - ${error}`) throw error } this.adapterName = this.adapter.name ?? this.adapter.constructor.name } async requireAdapterFrom (adapaterPath) { return await this.importAdapterFrom(adapaterPath) } async importAdapterFrom (adapterPath) { const forImport = this.prepareForImport(adapterPath) return await (await import(forImport)).default.use(this) } async importFromRepo (adapterPath) { return await (await import(adapterPath)).default.use(this) } // Public: Help Commands for Running Scripts. // // Returns an Array of help commands for running scripts. helpCommands () { return this.helpStrings.sort() } // Private: load help info from a loaded script. // // filePath - A String path to the file on disk. // // Returns nothing. parseHelp (filePath) { const scriptDocumentation = {} const body = fs.readFileSync(path.resolve(filePath), 'utf-8') const useStrictHeaderRegex = /^["']use strict['"];?\s+/ const lines = body.replace(useStrictHeaderRegex, '').split(/(?:\n|\r\n|\r)/) .reduce(toHeaderCommentBlock, { lines: [], isHeader: true }).lines .filter(Boolean) // remove empty lines let currentSection = null let nextSection this.logger.debug(`Parsing help for ${filePath}`) for (let i = 0, line; i < lines.length; i++) { line = lines[i] if (line.toLowerCase() === 'none') { continue } nextSection = line.toLowerCase().replace(':', '') if (Array.from(HUBOT_DOCUMENTATION_SECTIONS).indexOf(nextSection) !== -1) { currentSection = nextSection scriptDocumentation[currentSection] = [] } else { if (currentSection) { scriptDocumentation[currentSection].push(line) if (currentSection === 'commands') { this.helpStrings.push(line) } } } } if (currentSection === null) { this.logger.info(`${filePath} is using deprecated documentation syntax`) scriptDocumentation.commands = [] for (let i = 0, line, cleanedLine; i < lines.length; i++) { line = lines[i] if (line.match('-')) { continue } cleanedLine = line.slice(2, +line.length + 1 || 9e9).replace(/^hubot/i, this.name).trim() scriptDocumentation.commands.push(cleanedLine) this.helpStrings.push(cleanedLine) } } } // Public: A helper send function which delegates to the adapter's send // function. // // envelope - A Object with message, room and user details. // strings - One or more Strings for each message to send. // // Returns whatever the extending adapter returns. async send (envelope, ...strings) { return await this.adapter.send(envelope, ...strings) } // Public: A helper reply function which delegates to the adapter's reply // function. // // envelope - A Object with message, room and user details. // strings - One or more Strings for each message to send. // // Returns whatever the extending adapter returns. async reply (envelope, ...strings) { return await this.adapter.reply(envelope, ...strings) } // Public: A helper send function to message a room that the robot is in. // // room - String designating the room to message. // strings - One or more Strings for each message to send. // // Returns whatever the extending adapter returns. async messageRoom (room, ...strings) { const envelope = { room } return await this.adapter.send(envelope, ...strings) } // Public: A wrapper around the EventEmitter API to make usage // semantically better. // // event - The event name. // listener - A Function that is called with the event parameter // when event happens. // // Returns nothing. on (event, ...args) { this.events.on(event, ...args) } // Public: A wrapper around the EventEmitter API to make usage // semantically better. // // event - The event name. // args... - Arguments emitted by the event // // Returns nothing. emit (event, ...args) { this.events.emit(event, ...args) } // Public: Kick off the event loop for the adapter // // Returns whatever the adapter returns. async run () { this.setupCommandListeners() if (this.shouldEnableHttpd) { await this.setupExpress() } else { this.setupNullRouter() } await this.adapter.run() this.emit('running') } // Public: Gracefully shutdown the robot process // // Returns nothing. shutdown () { if (this.pingIntervalId != null) { clearInterval(this.pingIntervalId) } this.commands.clearPendingProposals() this.adapter?.close() if (this.server) { this.server.close() } this.brain.close() this.events.removeAllListeners() } prepareForImport (filePath) { return pathToFileURL(filePath) } // Public: The version of Hubot from npm // // Returns a String of the version number. parseVersion () { const pkg = fs.readFileSync(path.join(__dirname, '..', 'package.json')) this.version = pkg.version return this.version } // Public: Creates a scoped http client with chainable methods for // modifying the request. This doesn't actually make a request though. // Once your request is assembled, you can call `get()`/`post()`/etc to // send the request. // // url - String URL to access. // options - Optional options to pass on to the client // // Examples: // // robot.http("http://example.com") // # set a single header // .header('Authorization', 'bearer abcdef') // // # set multiple headers // .headers(Authorization: 'bearer abcdef', Accept: 'application/json') // // # add URI query parameters // .query(a: 1, b: 'foo & bar') // // # make the actual request // .get() (err, res, body) -> // console.log body // // # or, you can POST data // .post(data) (err, res, body) -> // console.log body // // # Can also set options // robot.http("https://example.com", {rejectUnauthorized: false}) // // Returns a ScopedClient instance. http (url, options) { const httpOptions = extend({}, this.globalHttpOptions, options) return HttpClient.create(url, httpOptions).header('User-Agent', `Hubot/${this.version}`) } herokuKeepalive (server) { let herokuUrl = process.env.HEROKU_URL if (herokuUrl) { if (!/\/$/.test(herokuUrl)) { herokuUrl += '/' } this.pingIntervalId = setInterval(() => { HttpClient.create(`${herokuUrl}hubot/ping`).post()((_err, res, body) => { this.logger.info('keep alive ping!') }) }, 5 * 60 * 1000) } } // Private: Install narrow command listeners for confirmation and invocation // // Returns nothing. setupCommandListeners () { // Use receiveMiddleware to intercept addressed messages for commands this.receiveMiddleware(async (context) => { const message = context.response.message // Only process TextMessages addressed to the bot if (message.constructor.name !== 'TextMessage') { return true // continue to other listeners } const text = message.text || '' // Check if message is addressed to bot (has bot name or alias at start) const robotPattern = new RegExp(`^[@]?${escapeRegExp(this.name)}[:,]?\\s+`, 'i') const aliasPattern = this.alias ? new RegExp(`^[@]?${escapeRegExp(this.alias)}[:,]?\\s+`, 'i') : null const isAddressed = robotPattern.test(text) || (aliasPattern && aliasPattern.test(text)) if (!isAddressed) { return true // not addressed to bot, continue to other listeners } // Strip bot name/alias from message let commandText = text.replace(robotPattern, '').trim() const contextData = { user: message.user, room: message.room, message: message, res: context.response } // Check for pending confirmation first const confirmationKey = this.commands._getConfirmationKey(message.user.id, message.room) const hasPending = this.commands.pendingProposals.has(confirmationKey) if (hasPending && /^(yes|y|no|n|cancel)$/i.test(commandText)) { const result = await this.commands.confirm(commandText, contextData) if (result) { if (result.executed) { context.response.reply(result.result || 'Command executed successfully') } else if (result.cancelled) { context.response.reply('Command cancelled') } message.done = true return false // stop processing } } // Try to parse as command invocation const parsed = this.commands.parse(commandText) if (!parsed) { return true // not a command, continue to other listeners } try { const result = await this.commands.invoke(commandText, contextData) if (result) { if (result.needsConfirmation) { const proposal = result.proposal context.response.reply(`Preview: ${proposal.preview}\n\nRun it? (yes/no)`) } else if (result.ok) { context.response.reply(result.result || 'Command executed successfully') } else { // Validation error let errorMsg = 'Invalid command:\n' if (result.missing && result.missing.length > 0) { errorMsg += `Missing required arguments: ${result.missing.join(', ')}\n` } if (result.errors && result.errors.length > 0) { errorMsg += `Errors: ${result.errors.join(', ')}` } context.response.reply(errorMsg) } message.done = true return false // stop processing } } catch (err) { context.response.reply(`Error executing command: ${err.message}`) message.done = true return false } return true // continue to other listeners }) } } function isCatchAllMessage (message) { return message instanceof Message.CatchAllMessage } function toHeaderCommentBlock (block, currentLine) { if (!block.isHeader) { return block } if (isCommentLine(currentLine)) { block.lines.push(removeCommentPrefix(currentLine)) } else { block.isHeader = false } return block } function isCommentLine (line) { return /^(#|\/\/)/.test(line) } function removeCommentPrefix (line) { return line.replace(/^[#/]+\s*/, '') } function extend (obj, ...sources) { sources.forEach((source) => { if (typeof source !== 'object') { return } Object.keys(source).forEach((key) => { obj[key] = source[key] }) }) return obj } function escapeRegExp (string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export default Robot ================================================ FILE: src/User.mjs ================================================ 'use strict' import { DataStoreUnavailable } from './DataStore.mjs' class User { // Represents a participating user in the chat. // // id - A unique ID for the user. // options - An optional Hash of key, value pairs for this user. constructor (id, options) { this.id = id if (options == null) { options = {} } // Define a getter method so we don't actually store the // robot itself on the user object, preventing it from // being serialized into the brain. if (options.robot) { const robot = options.robot delete options.robot this._getRobot = function () { return robot } } else { this._getRobot = function () { } } Object.keys(options).forEach((key) => { this[key] = options[key] }) if (!this.name) { this.name = this.id.toString() } } set (key, value) { this._checkDatastoreAvailable() return this._getDatastore()._set(this._constructKey(key), value, 'users') } get (key) { this._checkDatastoreAvailable() return this._getDatastore()._get(this._constructKey(key), 'users') } _constructKey (key) { return `${this.id}+${key}` } _checkDatastoreAvailable () { if (!this._getDatastore()) { throw new DataStoreUnavailable('datastore is not initialized') } } _getDatastore () { const robot = this._getRobot() if (robot) { return robot.datastore } } } export default User ================================================ FILE: src/adapters/Campfire.mjs ================================================ 'use strict' import HTTPS from 'node:https' import EventEmitter from 'node:events' import Adapter from '../Adapter.mjs' import { TextMessage, EnterMessage, LeaveMessage, TopicMessage } from '../Message.mjs' class Campfire extends Adapter { constructor(robot) { super(robot) this.timeouts = [] } send (envelope/* , ...strings */) { const strings = [].slice.call(arguments, 1) if (strings.length === 0) { return } const string = strings.shift() if (typeof string === 'function') { string() this.send.apply(this, [envelope].concat(strings)) return } this.bot.Room(envelope.room).speak(string, (error, data) => { if (error != null) { this.robot.logger.error(`Campfire send error: ${error}`) } this.send.apply(this, [envelope].concat(strings)) }) } emote (envelope/* , ...strings */) { const strings = [].slice.call(arguments, 1) this.send.apply(this, [envelope].concat(strings.map(str => `*${str}*`))) } reply (envelope/* , ...strings */) { const strings = [].slice.call(arguments, 1) this.send.apply(this, [envelope].concat(strings.map(str => `${envelope.user.name}: ${str}`))) } topic (envelope/* , ...strings */) { const strings = [].slice.call(arguments, 1) this.bot.Room(envelope.room).topic(strings.join(' / '), (err, data) => { if (err != null) { this.robot.logger.error(`Campfire topic error: ${err}`) } }) } play (envelope/* , ...strings */) { const strings = [].slice.call(arguments, 1) this.bot.Room(envelope.room).sound(strings.shift(), (err, data) => { if (err != null) { this.robot.logger.error(`Campfire sound error: ${err}`) } this.play.apply(this, [envelope].concat(strings)) }) } locked (envelope/* , ...strings */) { const strings = [].slice.call(arguments, 1) if (envelope.message.private) { this.send.apply(this, [envelope].concat(strings)) } this.bot.Room(envelope.room).lock(() => { strings.push(() => { // campfire won't send messages from just before a room unlock. 3000 // is the 3-second poll. const timeoutId = setTimeout(() => this.bot.Room(envelope.room).unlock(), 3000) this.timeouts.push(timeoutId) }) this.send.apply(this, [envelope].concat(strings)) }) } async run () { const self = this const options = { token: process.env.HUBOT_CAMPFIRE_TOKEN, rooms: process.env.HUBOT_CAMPFIRE_ROOMS, account: process.env.HUBOT_CAMPFIRE_ACCOUNT } const bot = new CampfireStreaming(options, this.robot, this) function withAuthor (callback) { return function (id, created, room, user, body) { bot.User(user, function (_err, userData) { if (userData.user) { const author = self.robot.brain.userForId(userData.user.id, userData.user) const userId = userData.user.id self.robot.brain.data.users[userId].name = userData.user.name self.robot.brain.data.users[userId].email_address = userData.user.email_address author.room = room return callback(id, created, room, user, body, author) } }) } } bot.on('TextMessage', withAuthor(function (id, created, room, user, body, author) { if (bot.info.id !== author.id) { const message = new TextMessage(author, body, id) message.private = bot.private[room] self.receive(message) } })) bot.on('EnterMessage', withAuthor(function (id, created, room, user, body, author) { if (bot.info.id !== author.id) { self.receive(new EnterMessage(author, null, id)) } })) bot.on('LeaveMessage', withAuthor(function (id, created, room, user, body, author) { if (bot.info.id !== author.id) { self.receive(new LeaveMessage(author, null, id)) } })) bot.on('TopicChangeMessage', withAuthor(function (id, created, room, user, body, author) { if (bot.info.id !== author.id) { self.receive(new TopicMessage(author, body, id)) } })) bot.on('LockMessage', withAuthor((id, created, room, user, body, author) => { bot.private[room] = true })) bot.on('UnlockMessage', withAuthor((id, created, room, user, body, author) => { bot.private[room] = false })) bot.Me(function (_err, data) { bot.info = data.user bot.name = bot.info.name return Array.from(bot.rooms).map(roomId => (roomId => bot.Room(roomId).join((_err, callback) => bot.Room(roomId).listen()))(roomId)) }) bot.on('reconnect', roomId => bot.Room(roomId).join((_err, callback) => bot.Room(roomId).listen())) this.bot = bot self.emit('connected') } close() { // Clear all pending timeouts for (const timeoutId of this.timeouts) { clearTimeout(timeoutId) } this.timeouts = [] super.close() } } class CampfireStreaming extends EventEmitter { constructor (options, robot, adapter) { super() this.robot = robot this.adapter = adapter if (options.token == null || options.rooms == null || options.account == null) { this.robot.logger.error('Not enough parameters provided. I need a token, rooms and account') process.exit(1) } this.token = options.token this.rooms = options.rooms.split(',') this.account = options.account this.host = this.account + '.campfirenow.com' this.authorization = `Basic ${Buffer.from(`${this.token}:x`).toString('base64')}` this.private = {} } Rooms (callback) { return this.get('/rooms', callback) } User (id, callback) { return this.get(`/users/${id}`, callback) } Me (callback) { return this.get('/users/me', callback) } Room (id) { const self = this const logger = this.robot.logger return { show (callback) { return self.get(`/room/${id}`, callback) }, join (callback) { return self.post(`/room/${id}/join`, '', callback) }, leave (callback) { return self.post(`/room/${id}/leave`, '', callback) }, lock (callback) { return self.post(`/room/${id}/lock`, '', callback) }, unlock (callback) { return self.post(`/room/${id}/unlock`, '', callback) }, // say things to this channel on behalf of the token user paste (text, callback) { return this.message(text, 'PasteMessage', callback) }, topic (text, callback) { const body = { room: { topic: text } } return self.put(`/room/${id}`, body, callback) }, sound (text, callback) { return this.message(text, 'SoundMessage', callback) }, speak (text, callback) { const body = { message: { body: text } } return self.post(`/room/${id}/speak`, body, callback) }, message (text, type, callback) { const body = { message: { body: text, type } } return self.post(`/room/${id}/speak`, body, callback) }, // listen for activity in channels listen () { const headers = { Host: 'streaming.campfirenow.com', Authorization: self.authorization, 'User-Agent': `Hubot/${this.robot != null ? this.robot.version : undefined} (${this.robot != null ? this.robot.name : undefined})` } const options = { agent: false, host: 'streaming.campfirenow.com', port: 443, path: `/room/${id}/live.json`, method: 'GET', headers } const request = HTTPS.request(options, function (response) { response.setEncoding('utf8') let buf = '' response.on('data', function (chunk) { if (chunk === ' ') { // campfire api sends a ' ' heartbeat every 3s } else if (chunk.match(/^\s*Access Denied/)) { return logger.error(`Campfire error on room ${id}: ${chunk}`) } else { // api uses newline terminated json payloads // buffer across tcp packets and parse out lines buf += chunk return (() => { let offset const result = [] while ((offset = buf.indexOf('\r')) > -1) { let item const part = buf.substr(0, offset) buf = buf.substr(offset + 1) if (part) { try { const data = JSON.parse(part) item = self.emit(data.type, data.id, data.created_at, data.room_id, data.user_id, data.body) } catch (error) { item = logger.error(`Campfire data error: ${error}\n${error.stack}`) } } result.push(item) } return result })() } }) response.on('end', function () { logger.error(`Streaming connection closed for room ${id}. :(`) const timeoutId = setTimeout(() => self.emit('reconnect', id), 5000) self.adapter.timeouts.push(timeoutId) }) return response.on('error', err => logger.error(`Campfire listen response error: ${err}`)) }) request.on('error', err => logger.error(`Campfire listen request error: ${err}`)) return request.end() } } } get (path, callback) { return this.request('GET', path, null, callback) } post (path, body, callback) { return this.request('POST', path, body, callback) } put (path, body, callback) { return this.request('PUT', path, body, callback) } request (method, path, body, callback) { const logger = this.robot.logger const headers = { Authorization: this.authorization, Host: this.host, 'Content-Type': 'application/json', 'User-Agent': `Hubot/${this.robot != null ? this.robot.version : undefined} (${this.robot != null ? this.robot.name : undefined})` } const options = { agent: false, host: this.host, port: 443, path, method, headers } if (method === 'POST' || method === 'PUT') { if (typeof body !== 'string') { body = JSON.stringify(body) } body = Buffer.from(body) options.headers['Content-Length'] = body.length } const request = HTTPS.request(options, function (response) { let data = '' response.on('data', chunk => { data += chunk }) response.on('end', function () { if (response.statusCode >= 400) { switch (response.statusCode) { case 401: throw new Error('Invalid access token provided') default: logger.error(`Campfire HTTPS status code: ${response.statusCode}`) logger.error(`Campfire HTTPS response data: ${data}`) } } if (callback) { try { return callback(null, JSON.parse(data)) } catch (_err) { return callback(null, data || {}) } } }) return response.on('error', function (err) { logger.error(`Campfire HTTPS response error: ${err}`) return callback(err, {}) }) }) if (method === 'POST' || method === 'PUT') { request.end(body, 'binary') } else { request.end() } return request.on('error', err => logger.error(`Campfire request error: ${err}`)) } } export default { use (robot) { return new Campfire(robot) } } ================================================ FILE: src/adapters/Shell.mjs ================================================ 'use strict' import { stat, writeFile, unlink, appendFile, readFile } from 'node:fs/promises' import readline from 'node:readline' import Adapter from '../Adapter.mjs' import { TextMessage } from '../Message.mjs' const historySize = process.env.HUBOT_SHELL_HISTSIZE != null ? parseInt(process.env.HUBOT_SHELL_HISTSIZE) : 1024 const historyPath = '.hubot_history' const completer = line => { const completions = '\\q exit \\? help \\c clear'.split(' ') const hits = completions.filter((c) => c.startsWith(line)) // Show all completions if none found return [hits.length ? hits : completions, line] } const showHelp = () => { console.log('usage:') console.log('\\q, exit - close Shell and exit') console.log('\\?, help - show this help') console.log('\\c, clear - clear screen') } const bold = str => `\x1b[1m${str}\x1b[22m` const green = str => `\x1b[32m${str}\x1b[0m` const levelColors = { error: '\x1b[31m', warn: '\x1b[33m', debug: '\x1b[35m', info: '\x1b[34m', trace: '\x1b[36m', fatal: '\x1b[91m' } const reset = '\x1b[0m' class Shell extends Adapter { #rl = null #levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] #logLevel = 'info' #levelPriorities = {} constructor (robot) { super(robot) this.name = 'Shell' this.#logLevel = process.env.HUBOT_LOG_LEVEL || this.#logLevel this.#levelPriorities = this.#levels.reduce((acc, current, idx) => { acc[current] = idx return acc }, {}) this.robot.on('scripts have loaded', () => { this.#rl?.prompt() }) } async send (envelope, ...strings) { this.#rl?.prompt() Array.from(strings).forEach(str => console.log(bold(str))) } async emote (envelope, ...strings) { Array.from(strings).map(str => this.send(envelope, `* ${str}`)) } async reply (envelope, ...strings) { strings = strings.map((s) => `${envelope.user.name}: ${s}`) await this.send(envelope, ...strings) } async run () { try { const stats = await stat(historyPath) if (stats.size > historySize) { await unlink(historyPath) await writeFile(historyPath, '') } } catch (error) { console.log(error) await writeFile(historyPath, '') } this.#rl = readline.createInterface({ input: this.robot.stdin ?? process.stdin, output: this.robot.stdout ?? process.stdout, prompt: green(`${this.robot.name ?? this.robot.alias}> `), completer }) this.#rl.on('line', async (line) => { const input = line.trim() switch (input) { case '\\q': case 'exit': this.#rl.close() process.exit(0) break case '\\?': case 'help': showHelp() this.#rl.prompt() break case '\\c': case 'clear': this.#rl.write(null, { ctrl: true, name: 'l' }) this.#rl.prompt() break } if (input.length === 0) { this.#rl.prompt() return } if (input.length > 0) { this.#rl.history.push(input) } let userId = process.env.HUBOT_SHELL_USER_ID || '1' if (userId.match(/A\d+z/)) { userId = parseInt(userId) } const userName = process.env.HUBOT_SHELL_USER_NAME || 'Shell' const user = this.robot.brain.userForId(userId, { name: userName, room: 'Shell' }) const message = new TextMessage(user, input, Date.now()) if (!message.text.startsWith(this.robot.name) && !message.text.startsWith(this.robot.alias)) { message.text = `${this.robot.name} ${message.text}` } await this.receive(message) this.#rl.prompt() }) this.#rl.on('history', async (history) => { if (history.length === 0) return await appendFile(historyPath, `${history[0]}\n`) }) const existingHistory = (await readFile(historyPath, 'utf8')).split('\n') existingHistory.reverse().forEach(line => this.#rl.history.push(line)) const configuredPriority = this.#levelPriorities[this.#logLevel] const noop = async () => {} this.#levels.forEach(level => { const priority = this.#levelPriorities[level] if (priority >= configuredPriority) { this.robot.logger[level] = async (...args) => { const color = levelColors[level] || '' const msg = `${color}[${level}]${reset} ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}` await this.send({ user: { name: 'Logger', room: 'Shell' } }, msg) } } else { this.robot.logger[level] = noop } }) try { this.emit('connected', this) } catch (error) { console.log(error) } } close () { super.close() if (this.#rl?.close) { this.#rl.close() } } } // Prevent output buffer "swallowing" every other character on OSX / Node version > 16.19.0. process.stdout._handle.setBlocking(false) export default { use (robot) { return new Shell(robot) } } ================================================ FILE: src/datastores/Memory.mjs ================================================ 'use strict' import { DataStore } from '../DataStore.mjs' class InMemoryDataStore extends DataStore { constructor (robot) { super(robot) this.data = { global: {}, users: {} } } async _get (key, table) { return Promise.resolve(this.data[table][key]) } async _set (key, value, table) { return Promise.resolve(this.data[table][key] = value) } } export default InMemoryDataStore ================================================ FILE: test/AdapterName_test.mjs ================================================ import test from 'node:test' import assert from 'node:assert/strict' import { Robot, Adapter } from '../index.mjs' class InMemoryAdapter extends Adapter { } function getRobotWithAdapter (adapter) { return new Robot({ async use (robot) { adapter.robot = robot return adapter } }, false, 'Hubot', 't-bot') } await test('Adapter Name', async (t) => { await t.test('Adapter argument is an object with user function', async () => { const adapter = new InMemoryAdapter() const robot = getRobotWithAdapter(adapter) await robot.loadAdapter() assert.equal(robot.adapterName, 'InMemoryAdapter') }) await t.test('Adapter argument is null', async () => { const robot = new Robot(null, false, 'Hubot', 't-bot') await robot.loadAdapter() assert.equal(robot.adapterName, 'Shell') }) await t.test('Adapter argument is a file path', async () => { const robot = new Robot('../test/fixtures/MockAdapter.mjs', false, 'Hubot', 't-bot') await robot.loadAdapter() assert.equal(robot.adapterName, 'MockAdapter') }) }) ================================================ FILE: test/Adapter_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import Adapter from '../src/Adapter.mjs' import { TextMessage } from '../src/Message.mjs' import User from '../src/User.mjs' describe('Adapter', () => { let robot = null beforeEach(() => { robot = { receive (msg) {} } }) describe('Public API', () => { let adapter = null beforeEach(() => { adapter = new Adapter(robot) }) afterEach(() => { adapter.close() process.removeAllListeners() }) it('assigns robot', () => { assert.deepEqual(adapter.robot, robot, 'The adapter should have a reference to the robot.') }) describe('send', () => { it('is a function', () => { assert.ok(typeof adapter.send === 'function', 'The adapter should have a send method.') }) it('does nothing', () => { adapter.send({}, 'nothing') }) }) describe('reply', () => { it('is a function', () => { assert.ok(typeof adapter.reply === 'function', 'The adapter should have a reply method.') }) it('does nothing', () => { adapter.reply({}, 'nothing') }) }) describe('emote', () => { it('is a function', () => { assert.ok(typeof adapter.emote === 'function', 'The adapter should have a emote method.') }) it('does nothing', () => { adapter.emote({}, 'nothing') }) }) describe('topic', () => { it('is a function', () => { assert.ok(typeof adapter.topic === 'function', 'The adapter should have a topic method.') }) it('does nothing', () => { adapter.topic({}, 'nothing') }) }) describe('play', () => { it('is a function', () => { assert.ok(typeof adapter.play === 'function', 'The adapter should have a play method.') }) it('does nothing', () => { adapter.play({}, 'nothing') }) }) describe('run', () => { it('is a function', () => { assert.ok(typeof adapter.run === 'function', 'The adapter should have a run method.') }) it('does nothing', async () => { await adapter.run() }) }) describe('close', () => { it('is a function', () => { assert.ok(typeof adapter.close === 'function', 'The adapter should have a close method.') }) it('does nothing', () => { adapter.close() }) }) }) it('dispatches received messages to the robot', (t, done) => { const adapter = new Adapter(robot) const message = new TextMessage(new User('node'), 'hello', 1) robot.receive = (msg) => { assert.deepEqual(msg, message, 'The message should be passed through.') done() } adapter.receive(message) adapter.close() }) }) ================================================ FILE: test/Brain_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { User, Robot, Brain } from '../index.mjs' import mockAdapter from './fixtures/MockAdapter.mjs' describe('Brain', () => { let mockRobot = null let user1 = null let user2 = null let user3 = null beforeEach(async () => { mockRobot = new Robot(mockAdapter, false, 'TestHubot') await mockRobot.loadAdapter() await mockRobot.run() user1 = mockRobot.brain.userForId('1', { name: 'Guy One' }) user2 = mockRobot.brain.userForId('2', { name: 'Guy One Two' }) user3 = mockRobot.brain.userForId('3', { name: 'Girl Three' }) }) afterEach(() => { mockRobot.shutdown() process.removeAllListeners() }) describe('Unit Tests', () => { describe('#mergeData', () => { it('performs a proper merge with the new data taking precedent', () => { mockRobot.brain.data = { 1: 'old', 2: 'old' } mockRobot.brain.mergeData({ 2: 'new' }) assert.deepEqual(mockRobot.brain.data, { 1: 'old', 2: 'new' }, 'The data should be merged properly.') }) it('emits a loaded event with the new data', (t, done) => { const loadedListener = (data) => { assert.ok(typeof data === 'object', 'data should be an object.') mockRobot.brain.off('loaded', loadedListener) done() } mockRobot.brain.on('loaded', loadedListener) mockRobot.brain.mergeData({}) }) it('coerces loaded data into User objects', () => { mockRobot.brain.mergeData({ users: { 4: { name: 'new', id: '4' } } }) const user = mockRobot.brain.userForId('4') assert.ok(user instanceof User) assert.equal(user.id, '4') assert.equal(user.name, 'new') }) }) describe('#save', () => { it('emits a save event', (t, done) => { const saveListener = (data) => { assert.deepEqual(data, mockRobot.brain.data) mockRobot.brain.off('save', saveListener) done() } mockRobot.brain.on('save', saveListener) mockRobot.brain.save() }) }) describe('#resetSaveInterval', () => { it('updates the auto-save interval', async () => { let wasCalled = false const shouldNotBeCalled = (data) => { assert.fail('save event should not have been emitted') } const shouldBeCalled = (data) => { mockRobot.brain.off('save', shouldBeCalled) wasCalled = true } mockRobot.brain.on('save', shouldNotBeCalled) mockRobot.brain.on('save', shouldBeCalled) // make sure autosave is on mockRobot.brain.setAutoSave(true) // default is 5s mockRobot.brain.resetSaveInterval(6) await Promise.all([ new Promise((resolve, reject) => { setTimeout(() => { assert.deepEqual(wasCalled, true, 'save event should have been emitted') resolve() }, 1000 * 6) }), new Promise((resolve, reject) => { setTimeout(() => { assert.notEqual(wasCalled, true) mockRobot.brain.off('save', shouldNotBeCalled) resolve() }, 1000 * 5) }) ]) }) }) describe('#close', () => { it('saves', (t, done) => { const saveListener = data => { mockRobot.brain.off('save', saveListener) assert.ok(data) done() } mockRobot.brain.on('save', saveListener) mockRobot.brain.close() }) it('emits a close event', (t, done) => { const closeListener = () => { mockRobot.brain.off('close', closeListener) assert.ok(true) done() } mockRobot.brain.on('close', closeListener) mockRobot.brain.close() }) it('saves before emitting the close event', (t, done) => { let wasSaveCalled = false const saveListener = data => { mockRobot.brain.off('save', saveListener) wasSaveCalled = true } const closeListener = () => { mockRobot.brain.off('close', closeListener) assert.ok(wasSaveCalled) done() } mockRobot.brain.on('save', saveListener) mockRobot.brain.on('close', closeListener) mockRobot.brain.close() }) it('stops auto-saving', (t, done) => { // make sure autosave is on mockRobot.brain.setAutoSave(true) mockRobot.brain.close() // set up the spy after because 'close' calls 'save' const saveListener = data => { assert.fail('save event should not have been emitted') } mockRobot.brain.on('save', saveListener) setTimeout(() => { assert.ok(true) mockRobot.brain.off('save', saveListener) done() }, 1000 * 10) }) }) describe('#get', () => { it('returns the saved value', () => { const brain = new Brain(mockRobot) brain.set('test-key', 'value') assert.equal(brain.get('test-key'), 'value') brain.close() }) it('returns null if object is not found', () => { const brain = new Brain(mockRobot) assert.equal(brain.get('not a real key'), null) brain.close() }) }) describe('#set', () => { it('sets multiple keys at once if an object is provided', () => { mockRobot.brain.data._private = { key1: 'val1', key2: 'val1' } mockRobot.brain.set({ key2: 'val2', key3: 'val2' }) assert.deepEqual(mockRobot.brain.data._private, { key1: 'val1', key2: 'val2', key3: 'val2' }) }) // Unable to understand why this behavior is needed, but adding a test // case to protect it it('emits loaded', (t, done) => { const loadedListener = (data) => { assert.deepEqual(data, mockRobot.brain.data) mockRobot.brain.off('loaded', loadedListener) done() } mockRobot.brain.on('loaded', loadedListener) mockRobot.brain.set('test-key', 'value') }) it('returns the mockRobot.brain', () => { assert.deepEqual(mockRobot.brain.set('test-key', 'value'), mockRobot.brain) }) }) describe('#remove', () => it('removes the specified key', () => { mockRobot.brain.set('test-key', 'value') mockRobot.brain.remove('test-key') assert.deepEqual(Object.keys(mockRobot.brain.data._private).includes('test-key'), false) })) describe('#userForId', () => { it('returns the user object', () => { const brain = new Brain(mockRobot) brain.userForId('1', user1) assert.deepEqual(brain.userForId('1'), user1) brain.close() }) it('does an exact match', () => { const user4 = mockRobot.brain.userForId('FOUR') assert.notDeepEqual(mockRobot.brain.userForId('four'), user4) }) // Cannot understand why this behavior is needed, but adding a test case // to protect it it('recreates the user if the room option differs from the user object', () => { assert.equal(mockRobot.brain.userForId(1).room, undefined) // undefined -> having a room const newUser1 = mockRobot.brain.userForId(1, { room: 'room1' }) assert.notDeepEqual(newUser1, user1) // changing the room const newUser2 = mockRobot.brain.userForId(1, { room: 'room2' }) assert.notDeepEqual(newUser2, newUser1) }) describe('when there is no matching user ID', () => { it('creates a new User', () => { assert.notEqual(Object.keys(mockRobot.brain.data.users).includes('all-new-user'), true) const newUser = mockRobot.brain.userForId('all-new-user') assert.ok(newUser instanceof User) assert.equal(newUser.id, 'all-new-user') assert.ok(Object.keys(mockRobot.brain.data.users).includes('all-new-user')) }) it('passes the provided options to the new User', () => { const brain = new Brain(mockRobot) const newUser = brain.userForId('all-new-user', { name: 'All New User', prop: 'mine' }) assert.equal(newUser.name, 'All New User') assert.equal(newUser.prop, 'mine') brain.close() }) }) }) describe('#userForName', () => { it('returns the user with a matching name', () => { const user = { id: 'user-for-name-guy-one', name: 'Guy One' } const brain = new Brain(mockRobot) const guy = brain.userForId('user-for-name-guy-one', user) assert.deepEqual(brain.userForName('Guy One'), guy) brain.close() }) it('does a case-insensitive match', () => { const user = { name: 'Guy One' } const brain = new Brain(mockRobot) const guy = brain.userForId('user-for-name-guy-one-case-insensitive', user) assert.deepEqual(brain.userForName('guy one'), guy) brain.close() }) it('returns null if no user matches', () => { assert.equal(mockRobot.brain.userForName('not a real user'), null) }) }) describe('#usersForRawFuzzyName', () => { it('does a case-insensitive match', () => { const brain = new Brain(mockRobot) const guy = brain.userForId('1', user1) const guy2 = brain.userForId('2', user2) assert.ok(brain.usersForRawFuzzyName('guy').includes(guy) && brain.usersForRawFuzzyName('guy').includes(guy2)) brain.close() }) it('returns all matching users (prefix match) when there is not an exact match (case-insensitive)', () => { const brain = new Brain(mockRobot) const guy = brain.userForId('1', user1) const guy2 = brain.userForId('2', user2) assert.ok(brain.usersForRawFuzzyName('Guy').includes(guy) && brain.usersForRawFuzzyName('Guy').includes(guy2)) brain.close() }) it('returns all matching users (prefix match) when there is an exact match (case-insensitive)', () => { const brain = new Brain(mockRobot) const girl = brain.userForId('1', user1) const girl2 = brain.userForId('2', user2) // Matched case assert.deepEqual(brain.usersForRawFuzzyName('Guy One'), [girl, girl2]) // Mismatched case assert.deepEqual(brain.usersForRawFuzzyName('guy one'), [girl, girl2]) brain.close() }) it('returns an empty array if no users match', () => { const result = mockRobot.brain.usersForRawFuzzyName('not a real user') assert.equal(result.length, 0) }) }) describe('#usersForFuzzyName', () => { it('does a case-insensitive match', () => { const brain = new Brain(mockRobot) const girl = brain.userForId('1', user1) const girl2 = brain.userForId('2', user2) assert.ok(brain.usersForFuzzyName('guy').includes(girl) && brain.usersForFuzzyName('guy').includes(girl2)) brain.close() }) it('returns all matching users (prefix match) when there is not an exact match', () => { const brain = new Brain(mockRobot) const girl = brain.userForId('1', user1) const girl2 = brain.userForId('2', user2) assert.ok(brain.usersForFuzzyName('Guy').includes(girl) && brain.usersForFuzzyName('Guy').includes(girl2)) brain.close() }) it('returns just the user when there is an exact match (case-insensitive)', () => { const brain = new Brain(mockRobot) const girl = brain.userForId('1', user1) brain.userForId('2', user2) // Matched case assert.deepEqual(brain.usersForFuzzyName('Guy One'), [girl]) // Mismatched case assert.deepEqual(brain.usersForFuzzyName('guy one'), [girl]) brain.close() }) it('returns an empty array if no users match', () => { const result = mockRobot.brain.usersForFuzzyName('not a real user') assert.equal(result.length, 0) }) }) }) describe('Auto-Save', () => { it('is on by default', () => { assert.deepEqual(mockRobot.brain.autoSave, true) }) it('automatically saves every 5 seconds when turned on', (t, done) => { let wasCalled = false const saveListener = data => { mockRobot.brain.off('save', saveListener) wasCalled = true } mockRobot.brain.on('save', saveListener) mockRobot.brain.setAutoSave(true) setTimeout(() => { mockRobot.brain.off('save', saveListener) assert.deepEqual(wasCalled, true) done() }, 1000 * 5.5) }) it('does not auto-save when turned off', (t, done) => { let wasCalled = false const saveListener = data => { wasCalled = true assert.fail('save event should not have been emitted') } mockRobot.brain.setAutoSave(false) mockRobot.brain.on('save', saveListener) setTimeout(() => { assert.notEqual(wasCalled, true) mockRobot.brain.off('save', saveListener) done() }, 1000 * 10) }) }) describe('User Searching', () => { it('finds users by ID', () => { assert.deepEqual(mockRobot.brain.userForId('1'), user1) }) it('finds users by exact name', () => { assert.deepEqual(mockRobot.brain.userForName('Guy One'), user1) }) it('finds users by fuzzy name (prefix match)', () => { const result = mockRobot.brain.usersForFuzzyName('Guy') assert.ok(result.includes(user1) && result.includes(user2)) assert.ok(!result.includes(user3)) }) it('returns User objects, not POJOs', () => { assert.ok(mockRobot.brain.userForId('1') instanceof User) for (const user of mockRobot.brain.usersForFuzzyName('Guy')) { assert.ok(user instanceof User) } for (const user of mockRobot.brain.usersForRawFuzzyName('Guy One')) { assert.ok(user instanceof User) } }) }) }) ================================================ FILE: test/CommandBus_test.mjs ================================================ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert' import fs from 'node:fs' import path from 'node:path' import { CommandBus } from '../src/CommandBus.mjs' // Fake robot for testing class FakeRobot { constructor() { this.brain = { users: () => ({ '1': { id: '1', name: 'alice' }, '2': { id: '2', name: 'bob' }, '3': { id: '3', name: 'charlie' } }) } } } describe('CommandBus', () => { let commandBus let robot let logPath beforeEach(() => { robot = new FakeRobot() logPath = path.join(process.cwd(), `test-commands-${Date.now()}.ndjson`) commandBus = new CommandBus(robot, { logPath, disableLogging: true }) }) afterEach(async () => { commandBus.clearPendingProposals() try { await fs.promises.unlink(logPath) } catch (err) { } }) describe('register()', () => { it('should register a command with minimal spec', () => { const spec = { id: 'test.hello', description: 'Say hello', handler: async (ctx) => 'Hello!' } commandBus.register(spec) const cmd = commandBus.getCommand('test.hello') assert.strictEqual(cmd.id, 'test.hello') assert.strictEqual(cmd.description, 'Say hello') }) it('should throw error when registering duplicate command id', () => { const spec = { id: 'test.duplicate', description: 'Test', handler: async () => {} } commandBus.register(spec) assert.throws(() => { commandBus.register(spec) }, /already registered/) }) it('should allow updating a command', () => { const spec1 = { id: 'test.update', description: 'First version', handler: async () => 'v1' } const spec2 = { id: 'test.update', description: 'Second version', handler: async () => 'v2' } commandBus.register(spec1) commandBus.register(spec2, { update: true }) const cmd = commandBus.getCommand('test.update') assert.strictEqual(cmd.description, 'Second version') }) it('should allow unregistering a command', () => { const spec = { id: 'test.remove', description: 'Will be removed', handler: async () => {} } commandBus.register(spec) assert.ok(commandBus.getCommand('test.remove')) commandBus.unregister('test.remove') assert.strictEqual(commandBus.getCommand('test.remove'), undefined) }) }) describe('parse()', () => { beforeEach(() => { commandBus.register({ id: 'tickets.create', description: 'Create a ticket', handler: async () => {} }) commandBus.register({ id: 'deploy.run', description: 'Deploy', handler: async () => {} }) }) it('should parse command with quoted string arguments', () => { const result = commandBus.parse('tickets.create --title "VPN down" --priority high') assert.ok(result) assert.strictEqual(result.commandId, 'tickets.create') assert.strictEqual(result.args.title, 'VPN down') assert.strictEqual(result.args.priority, 'high') }) it('should parse backslash-escaped quotes inside quoted strings', () => { const result = commandBus.parse('tickets.create --message "She said \\\"hello\\\""') assert.ok(result) assert.strictEqual(result.commandId, 'tickets.create') assert.strictEqual(result.args.message, 'She said "hello"') }) it('should parse command with key:value arguments', () => { const result = commandBus.parse('tickets.create title:"VPN down" priority:high') assert.ok(result) assert.strictEqual(result.commandId, 'tickets.create') assert.strictEqual(result.args.title, 'VPN down') assert.strictEqual(result.args.priority, 'high') }) it('should parse boolean flags', () => { const result = commandBus.parse('deploy.run --dry-run --force') assert.ok(result) assert.strictEqual(result.commandId, 'deploy.run') assert.strictEqual(result.args['dry-run'], true) assert.strictEqual(result.args.force, true) }) it('should return null for messages not starting with prefix', () => { const result = commandBus.parse('just a normal chat message') assert.strictEqual(result, null) }) it('should handle mixed argument styles', () => { const result = commandBus.parse('tickets.create --title "VPN down" priority:high --assignee matt') assert.ok(result) assert.strictEqual(result.args.title, 'VPN down') assert.strictEqual(result.args.priority, 'high') assert.strictEqual(result.args.assignee, 'matt') }) it('should return null for unknown command or alias', () => { const result = commandBus.parse('unknown.command --arg value') assert.strictEqual(result, null) }) it('should use schema hints to disambiguate boolean flags', () => { commandBus.register({ id: 'test.schema', description: 'Test schema-aware parsing', args: { force: { type: 'boolean' }, count: { type: 'number' } }, handler: async () => {} }) const result = commandBus.parse('test.schema --force --count 5') assert.ok(result) assert.strictEqual(result.args.force, true) assert.strictEqual(result.args.count, '5') }) }) describe('aliases', () => { it('should normalize aliases and enforce uniqueness', () => { commandBus.register({ id: 'test.aliases', description: 'Test aliases', aliases: [' New Ticket ', 'new ticket', 'NEW TICKET', 'create ticket'], handler: async () => {} }) const cmd = commandBus.getCommand('test.aliases') assert.deepStrictEqual(cmd.aliases, ['New Ticket', 'create ticket']) assert.deepStrictEqual(cmd.normalizedAliases, ['new ticket', 'create ticket']) }) it('should reject invalid aliases', () => { assert.throws(() => { commandBus.register({ id: 'test.aliases.invalid', description: 'Invalid aliases', aliases: [' ', 123], handler: async () => {} }) }, /aliases/) }) it('should not execute or propose for alias invocation', async () => { let executed = false commandBus.register({ id: 'test.alias.invoke', description: 'Alias should not invoke', aliases: ['alias.invoke'], sideEffects: ['side effect'], handler: async () => { executed = true return 'done' } }) const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } const result = await commandBus.invoke('alias.invoke --arg value', context) assert.strictEqual(result, null) assert.strictEqual(executed, false) }) }) describe('validate()', () => { beforeEach(() => { commandBus.register({ id: 'test.validate', description: 'Test validation', args: { title: { type: 'string', required: true }, priority: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, count: { type: 'number', required: false }, active: { type: 'boolean', default: false } }, handler: async () => {} }) }) it('should apply defaults from schema', async () => { const result = await commandBus.validate('test.validate', { title: 'Test' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.title, 'Test') assert.strictEqual(result.args.priority, 'medium') assert.strictEqual(result.args.active, false) }) it('should reject missing required args', async () => { const result = await commandBus.validate('test.validate', {}, {}) assert.strictEqual(result.ok, false) assert.ok(result.missing.includes('title')) }) it('should reject invalid enum values', async () => { const result = await commandBus.validate('test.validate', { title: 'Test', priority: 'critical' }, {}) assert.strictEqual(result.ok, false) assert.ok(result.errors.some(e => e.includes('priority'))) }) it('should validate and convert number type', async () => { const result = await commandBus.validate('test.validate', { title: 'Test', count: '42' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.count, 42) }) it('should validate boolean type', async () => { const result = await commandBus.validate('test.validate', { title: 'Test', active: 'true' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.active, true) }) }) describe('user resolver', () => { beforeEach(() => { commandBus.register({ id: 'test.user', description: 'Test user resolution', args: { assignee: { type: 'user', required: true } }, handler: async () => {} }) }) it('should resolve user by name to brain user record', async () => { const result = await commandBus.validate('test.user', { assignee: 'alice' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.assignee.id, '1') assert.strictEqual(result.args.assignee.name, 'alice') }) it('should fail validation if user not found', async () => { const result = await commandBus.validate('test.user', { assignee: 'nonexistent' }, {}) assert.strictEqual(result.ok, false) assert.ok(result.errors.some(e => e.includes('assignee'))) }) }) describe('room resolver', () => { beforeEach(() => { commandBus.register({ id: 'test.room', description: 'Test room validation', args: { channel: { type: 'room', required: true } }, handler: async () => {} }) }) it('should validate room format', async () => { const result = await commandBus.validate('test.room', { channel: '#ops' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.channel, '#ops') }) it('should reject invalid room format', async () => { const result = await commandBus.validate('test.room', { channel: 'ops' }, {}) assert.strictEqual(result.ok, false) assert.ok(result.errors.some(e => e.includes('channel'))) }) it('should allow custom room resolver override', async () => { commandBus.registerTypeResolver('room', async (value) => { if (!value.startsWith('room:')) { throw new Error('room must start with room:') } return value }) const result = await commandBus.validate('test.room', { channel: 'room:ops' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.channel, 'room:ops') }) }) describe('date resolver', () => { beforeEach(() => { commandBus.register({ id: 'test.date', description: 'Test date parsing', args: { due: { type: 'date', required: true } }, handler: async () => {} }) }) it('should parse "today" keyword', async () => { const result = await commandBus.validate('test.date', { due: 'today' }, {}) assert.strictEqual(result.ok, true) assert.ok(result.args.due instanceof Date) }) it('should parse "tomorrow" keyword', async () => { const result = await commandBus.validate('test.date', { due: 'tomorrow' }, {}) assert.strictEqual(result.ok, true) assert.ok(result.args.due instanceof Date) }) it('should parse ISO date string', async () => { const result = await commandBus.validate('test.date', { due: '2026-02-15' }, {}) assert.strictEqual(result.ok, true) assert.ok(result.args.due instanceof Date) }) it('should reject invalid date', async () => { const result = await commandBus.validate('test.date', { due: 'invalid-date' }, {}) assert.strictEqual(result.ok, false) assert.ok(result.errors.some(e => e.includes('due'))) }) }) describe('custom type resolvers', () => { it('should allow registering custom type resolver', async () => { commandBus.registerTypeResolver('project_id', async (value) => { if (!value.startsWith('PRJ-')) { throw new Error('must start with PRJ-') } return value.toUpperCase() }) commandBus.register({ id: 'test.custom', description: 'Test custom type', args: { project: { type: 'project_id', required: true } }, handler: async () => {} }) const result = await commandBus.validate('test.custom', { project: 'PRJ-123' }, {}) assert.strictEqual(result.ok, true) assert.strictEqual(result.args.project, 'PRJ-123') }) it('should reject invalid custom type values', async () => { commandBus.registerTypeResolver('project_id', async (value) => { if (!value.startsWith('PRJ-')) { throw new Error('must start with PRJ-') } return value.toUpperCase() }) commandBus.register({ id: 'test.custom.invalid', description: 'Test custom type validation', args: { project: { type: 'project_id', required: true } }, handler: async () => {} }) const result = await commandBus.validate('test.custom.invalid', { project: 'invalid' }, {}) assert.strictEqual(result.ok, false) assert.ok(result.errors.some(e => e.includes('must start with PRJ-'))) }) it('should throw error for invalid resolver registration', () => { assert.throws(() => { commandBus.registerTypeResolver('', () => {}) }, /non-empty string/) assert.throws(() => { commandBus.registerTypeResolver('test', 'not a function') }, /must be a function/) }) }) describe('propose() and confirm()', () => { let executeCalled let executeArgs beforeEach(() => { executeCalled = false executeArgs = null commandBus.register({ id: 'test.sideeffect', description: 'Command with side effects', sideEffects: ['modifies database'], args: { action: { type: 'string', required: true } }, handler: async (ctx) => { executeCalled = true executeArgs = ctx.args return 'Command executed!' } }) }) it('should create pending proposal for side-effect command', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } const proposal = await commandBus.propose({ commandId: 'test.sideeffect', args: { action: 'delete' } }, context) assert.ok(proposal) assert.strictEqual(proposal.commandId, 'test.sideeffect') assert.ok(proposal.preview) assert.ok(proposal.confirmationKey) }) it('should execute on confirm("yes")', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } await commandBus.propose({ commandId: 'test.sideeffect', args: { action: 'delete' } }, context) const result = await commandBus.confirm('yes', context) assert.ok(result) assert.strictEqual(result.executed, true) assert.ok(executeCalled) assert.strictEqual(executeArgs.action, 'delete') }) it('should cancel on confirm("no")', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } await commandBus.propose({ commandId: 'test.sideeffect', args: { action: 'delete' } }, context) const result = await commandBus.confirm('no', context) assert.ok(result) assert.strictEqual(result.cancelled, true) assert.strictEqual(executeCalled, false) }) it('should cancel on confirm("cancel")', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } await commandBus.propose({ commandId: 'test.sideeffect', args: { action: 'delete' } }, context) const result = await commandBus.confirm('cancel', context) assert.ok(result) assert.strictEqual(result.cancelled, true) assert.strictEqual(executeCalled, false) }) it('should return null if no pending confirmation exists', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } const result = await commandBus.confirm('yes', context) assert.strictEqual(result, null) }) }) describe('_renderPreview()', () => { it('should escape quotes inside quoted values', () => { const preview = commandBus._renderPreview('test.preview', { message: 'test "quoted" text' }) assert.strictEqual(preview, 'test.preview --message "test \\\"quoted\\\" text"') }) }) describe('confirm policy', () => { it('should require confirmation when confirm=always', async () => { commandBus.register({ id: 'test.always', description: 'Always confirm', confirm: 'always', handler: async () => {} }) const needsConfirm = commandBus.needsConfirmation('test.always') assert.strictEqual(needsConfirm, true) }) it('should not require confirmation when confirm=never', async () => { commandBus.register({ id: 'test.never', description: 'Never confirm', confirm: 'never', handler: async () => {} }) const needsConfirm = commandBus.needsConfirmation('test.never') assert.strictEqual(needsConfirm, false) }) it('should require confirmation for commands with sideEffects', async () => { commandBus.register({ id: 'test.effects', description: 'Has side effects', sideEffects: ['deletes data'], handler: async () => {} }) const needsConfirm = commandBus.needsConfirmation('test.effects') assert.strictEqual(needsConfirm, true) }) }) describe('execute()', () => { it('should pass args and context to handler', async () => { let received commandBus.register({ id: 'test.execute.ctx', description: 'Execution context shape', handler: async (ctx) => { received = ctx return 'ok' } }) const context = { user: { id: 'user1', name: 'alice' }, room: 'room1', message: { id: 'm1' }, res: { id: 'r1' } } const result = await commandBus.execute('test.execute.ctx', { foo: 'bar' }, context) assert.strictEqual(result, 'ok') assert.deepStrictEqual(received.args, { foo: 'bar' }) assert.deepStrictEqual(received.context, context) }) }) describe('permissions', () => { beforeEach(() => { commandBus.register({ id: 'test.restricted', description: 'Restricted to specific rooms', permissions: { rooms: ['#ops', '#admin'] }, handler: async () => 'Success' }) }) it('should allow execution in allowed rooms', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: '#ops' } const result = await commandBus.execute('test.restricted', {}, context) assert.strictEqual(result, 'Success') }) it('should deny execution in disallowed rooms', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: '#general' } await assert.rejects( async () => await commandBus.execute('test.restricted', {}, context), /Permission denied/ ) }) it('should allow execution when user has required role', async () => { const permissionProvider = { hasRole: async (user, roles, context) => { return roles.includes('admin') && user.id === 'user1' } } const busCfg = new CommandBus(robot, { permissionProvider }) busCfg.register({ id: 'test.role.restricted', description: 'Restricted by role', permissions: { roles: ['admin'] }, handler: async () => 'Success' }) const context = { user: { id: 'user1', name: 'alice' }, room: '#general' } const result = await busCfg.execute('test.role.restricted', {}, context) assert.strictEqual(result, 'Success') }) it('should deny execution when user lacks required role', async () => { const permissionProvider = { hasRole: async (user, roles, context) => { return false } } const busCfg = new CommandBus(robot, { permissionProvider }) busCfg.register({ id: 'test.role.denied', description: 'Restricted by role', permissions: { roles: ['admin'] }, handler: async () => 'Success' }) const context = { user: { id: 'user1', name: 'alice' }, room: '#general' } await assert.rejects( async () => await busCfg.execute('test.role.denied', {}, context), /Permission denied: insufficient role/ ) }) it('should check multiple roles and allow if user has any required role', async () => { const permissionProvider = { hasRole: async (user, roles, context) => { const userRoles = new Set(['developer', 'reviewer']) return roles.some(role => userRoles.has(role)) } } const busCfg = new CommandBus(robot, { permissionProvider }) busCfg.register({ id: 'test.multi.role', description: 'Multiple roles allowed', permissions: { roles: ['admin', 'developer', 'lead'] }, handler: async () => 'Success' }) const context = { user: { id: 'user1', name: 'alice' }, room: '#general' } const result = await busCfg.execute('test.multi.role', {}, context) assert.strictEqual(result, 'Success') }) it('should allow execution without permissionProvider when roles defined', async () => { const busCfg = new CommandBus(robot) busCfg.register({ id: 'test.no.provider', description: 'Roles but no provider', permissions: { roles: ['admin'] }, handler: async () => 'Success' }) const context = { user: { id: 'user1', name: 'alice' }, room: '#general' } const result = await busCfg.execute('test.no.provider', {}, context) assert.strictEqual(result, 'Success') }) }) describe('invoke()', () => { beforeEach(() => { commandBus.register({ id: 'test.invoke', description: 'Test invoke pipeline', args: { name: { type: 'string', required: true } }, handler: async (ctx) => `Hello, ${ctx.args.name}!` }) }) it('should parse, validate, and execute in one call', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } const result = await commandBus.invoke('test.invoke --name alice', context) assert.ok(result) assert.strictEqual(result.result, 'Hello, alice!') }) it('should return validation errors if invalid', async () => { const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } const result = await commandBus.invoke('test.invoke', context) assert.ok(result) assert.strictEqual(result.ok, false) assert.ok(result.missing.includes('name')) }) it('should return help when --help flag is provided', async () => { let executed = false commandBus.register({ id: 'test.help.invoke', description: 'Help flag test', args: { name: { type: 'string', required: true } }, handler: async () => { executed = true return 'executed' } }) const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } const result = await commandBus.invoke('test.help.invoke --help', context) assert.ok(result) assert.strictEqual(result.ok, true) assert.strictEqual(result.helpOnly, true) assert.ok(result.result.includes('Usage:')) assert.strictEqual(executed, false) }) }) describe('listCommands()', () => { beforeEach(() => { commandBus.register({ id: 'admin.restart', description: 'Restart the bot', handler: async () => {} }) commandBus.register({ id: 'admin.status', description: 'Check bot status', handler: async () => {} }) commandBus.register({ id: 'user.profile', description: 'View user profile', handler: async () => {} }) }) it('should list all commands when no filter', () => { const commands = commandBus.listCommands() assert.strictEqual(commands.length, 3) }) it('should filter commands by prefix', () => { const commands = commandBus.listCommands({ prefix: 'admin' }) assert.strictEqual(commands.length, 2) assert.ok(commands.every(c => c.id.startsWith('admin'))) }) }) describe('getHelp()', () => { beforeEach(() => { commandBus.register({ id: 'test.help', description: 'Test command for help', aliases: ['help.test', 'test help'], args: { name: { type: 'string', required: true }, count: { type: 'number', default: 1 } }, examples: [ '/test.help --name alice', '/test.help --name bob --count 5' ], handler: async () => {} }) }) it('should return help text for a command', () => { const help = commandBus.getHelp('test.help') assert.ok(help) assert.ok(help.includes('test.help')) assert.ok(help.includes('Test command for help')) assert.ok(help.includes('Intent:')) assert.ok(help.includes('help.test')) assert.ok(help.includes('name')) assert.ok(help.includes('required')) }) }) describe('search()', () => { beforeEach(() => { commandBus.register({ id: 'tickets.create', description: 'Create a support ticket', aliases: ['ticket new', 'create ticket'], examples: ['/tickets.create --title "VPN down"'], handler: async () => {} }) commandBus.register({ id: 'tickets.status', description: 'Check ticket status', aliases: ['ticket status'], handler: async () => {} }) }) it('should rank exact alias match highest', () => { const results = commandBus.search('ticket new') assert.ok(results.length > 0) assert.strictEqual(results[0].id, 'tickets.create') assert.strictEqual(results[0].matchedOn, 'alias') }) it('should rank alias token overlap above description overlap', () => { const results = commandBus.search('ticket') assert.ok(results.length > 0) assert.strictEqual(results[0].matchedOn, 'alias') }) }) describe('aliasCollisions()', () => { it('should return collisions for normalized aliases', () => { commandBus.register({ id: 'cmd.one', description: 'First command', aliases: ['My Alias'], handler: async () => {} }) commandBus.register({ id: 'cmd.two', description: 'Second command', aliases: ['my alias'], handler: async () => {} }) const collisions = commandBus.aliasCollisions() assert.deepStrictEqual(collisions['my alias'], ['cmd.one', 'cmd.two']) }) }) describe('event emission', () => { it('should emit commands:registered event', (t, done) => { commandBus.on('commands:registered', (event) => { assert.strictEqual(event.commandId, 'test.event') done() }) commandBus.register({ id: 'test.event', description: 'Test event emission', handler: async () => {} }) }) it('should emit commands:invocation_parsed event', (t, done) => { commandBus.register({ id: 'test.parse', description: 'Test parse', handler: async () => {} }) commandBus.on('commands:invocation_parsed', (event) => { assert.strictEqual(event.commandId, 'test.parse') done() }) commandBus.parse('test.parse --arg value') }) }) describe('TTL for pending proposals', () => { it('should expire pending proposals after TTL', async (t) => { const shortTTL = 100 // 100ms const busWithShortTTL = new CommandBus(robot, { proposalTTL: shortTTL, disableLogging: true }) busWithShortTTL.register({ id: 'test.ttl', description: 'Test TTL', sideEffects: ['test'], handler: async () => 'Done' }) const context = { user: { id: 'user1', name: 'alice' }, room: 'room1' } await busWithShortTTL.propose({ commandId: 'test.ttl', args: {} }, context) // Wait for TTL to expire await new Promise(resolve => setTimeout(resolve, 150)) const result = await busWithShortTTL.confirm('yes', context) assert.strictEqual(result, null) // Should be expired // Clean up any remaining timers busWithShortTTL.clearPendingProposals() }) }) }) ================================================ FILE: test/Configuration_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { Robot } from '../index.mjs' import { resolve } from 'node:path' describe('Configuration', () => { describe('#robot', () => { let robot = null beforeEach(() => { robot = new Robot(null, false, 'TestHubot') }) afterEach(() => { robot.shutdown() }) it('Load files from configuration folder', async () => { await robot.load(resolve('.', 'configuration')) assert.ok(robot.config !== undefined) }) }) }) ================================================ FILE: test/DataStore_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import InMemoryDataStore from '../src/datastores/Memory.mjs' import Brain from '../src/Brain.mjs' describe('Datastore', () => { let robot = null beforeEach(() => { robot = { emit () {}, on () {}, receive (msg) {} } robot.brain = new Brain(robot) robot.datastore = new InMemoryDataStore(robot) robot.brain.userForId('1', { name: 'User One' }) robot.brain.userForId('2', { name: 'User Two' }) }) afterEach(() => { robot.brain.close() // Getting warning about too many listeners, so remove them all process.removeAllListeners() }) describe('global scope', () => { it('returns undefined for values not in the datastore', async () => { const value = await robot.datastore.get('blah') assert.deepEqual(value, undefined) }) it('can store simple values', async () => { await robot.datastore.set('key', 'value') const value = await robot.datastore.get('key') assert.equal(value, 'value') }) it('can store arbitrary JavaScript values', async () => { const object = { name: 'test', data: [1, 2, 3] } await robot.datastore.set('key', object) const value = await robot.datastore.get('key') assert.equal(value.name, 'test') assert.deepEqual(value.data, [1, 2, 3]) }) it('can dig inside objects for values', async () => { const object = { a: 'one', b: 'two' } await robot.datastore.set('key', object) const value = await robot.datastore.getObject('key', 'a') assert.equal(value, 'one') }) it('can set individual keys inside objects', async () => { const object = { a: 'one', b: 'two' } await robot.datastore.set('object', object) await robot.datastore.setObject('object', 'c', 'three') const value = await robot.datastore.get('object') assert.equal(value.a, 'one') assert.equal(value.b, 'two') assert.equal(value.c, 'three') }) it('creates an object from scratch when none exists', async () => { const datastore = new InMemoryDataStore(robot) await datastore.setObject('object', 'key', 'value') const value = await datastore.get('object') assert.deepEqual(value, { key: 'value' }) }) it('can append to an existing array', async () => { await robot.datastore.set('array', [1, 2, 3]) await robot.datastore.setArray('array', 4) const value = await robot.datastore.get('array') assert.deepEqual(value, [1, 2, 3, 4]) }) it('creates an array from scratch when none exists', async () => { const datastore = new InMemoryDataStore(robot) await datastore.setArray('array', 4) const value = await datastore.get('array') assert.deepEqual(value, [4]) }) it('creates an array with an array', async () => { const expected = [1, 2, 3] const datastore = new InMemoryDataStore(robot) await datastore.setArray('array', [1, 2, 3]) const actual = await datastore.get('array') assert.deepEqual(actual, expected) }) }) describe('User scope', () => { it('has access to the robot object', () => { const user = robot.brain.userForId('1') assert.deepEqual(user._getRobot(), robot) }) it('can store user data which is separate from global data', async () => { const user = robot.brain.userForId('1') await user.set('blah', 'blah') const userBlah = await user.get('blah') const datastoreBlah = await robot.datastore.get('blah') assert.notDeepEqual(userBlah, datastoreBlah) assert.equal(userBlah, 'blah') assert.deepEqual(datastoreBlah, undefined) }) it('stores user data separate per-user', async () => { const userOne = robot.brain.userForId('1') const userTwo = robot.brain.userForId('2') await userOne.set('blah', 'blah') const valueOne = await userOne.get('blah') const valueTwo = await userTwo.get('blah') assert.notDeepEqual(valueOne, valueTwo) assert.equal(valueOne, 'blah') assert.deepEqual(valueTwo, undefined) }) }) }) ================================================ FILE: test/Hubot_test.mjs ================================================ 'use strict' import { describe, it } from 'node:test' import assert from 'node:assert/strict' import path from 'node:path' import { spawn } from 'node:child_process' import { TextMessage, User } from '../index.mjs' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const root = __dirname.replace(/test$/, '') describe('Running bin/Hubot.mjs', () => { it('should load adapter from HUBOT_FILE environment variable', async () => { process.env.HUBOT_HTTPD = 'false' process.env.HUBOT_FILE = path.resolve(root, 'test', 'fixtures', 'MockAdapter.mjs') const hubot = (await import('../bin/Hubot.mjs')).default await hubot.loadFile(path.resolve(root, 'test', 'fixtures'), 'TestScript.mjs') while (!hubot.adapter) { await new Promise(resolve => setTimeout(resolve, 100)) } hubot.adapter.on('reply', (envelope, ...strings) => { assert.equal(strings[0], 'test response from .mjs script') delete process.env.HUBOT_FILE delete process.env.HUBOT_HTTPD hubot.shutdown() }) try { await hubot.receive(new TextMessage(new User('mocha', { room: '#mocha' }), '@Hubot test')) assert.deepEqual(hubot.hasLoadedTestMjsScript, true) assert.equal(hubot.name, 'Hubot') } finally { hubot.shutdown() } }) it('should output a help message when run with --help', (t, done) => { const hubot = process.platform === 'win32' ? spawn('node', ['./bin/Hubot.mjs', '--help']) : spawn('./bin/hubot', ['--help']) const expected = `Usage: hubot [options] -a, --adapter HUBOT_ADAPTER -f, --file HUBOT_FILE -c, --create HUBOT_CREATE -d, --disable-httpd HUBOT_HTTPD -h, --help -l, --alias HUBOT_ALIAS -n, --name HUBOT_NAME -r, --require PATH -t, --config-check -v, --version -e, --execute ` let actual = '' hubot.stdout.on('data', (data) => { actual += data.toString() }) hubot.stderr.on('data', (data) => { actual += data.toString() }) hubot.on('close', (code) => { assert.deepEqual(actual, expected) done() }) }) it('should execute the command when run with --execute or -e', (t, done) => { const expected = "HELO World! I'm Hubot." const commandText = 'helo' const env = Object.assign({}, process.env, { NOLOG: 'off' }) const hubot = process.platform === 'win32' ? spawn('node', ['./bin/Hubot.mjs', '-d', '--execute', commandText, '-r', 'test/scripts'], { env }) : spawn('./bin/hubot', ['-d', '--execute', commandText, '-r', 'test/scripts'], { env }) let actual = '' hubot.stdout.on('data', (data) => { actual += data.toString() }) hubot.stderr.on('data', (data) => { actual += data.toString() }) hubot.on('close', (code) => { assert.ok(actual.includes(expected)) done() }) }) }) describe('Running hubot with args', () => { it('should not start web service when --disable-httpd is passed', (t, done) => { const hubot = process.platform === 'win32' ? spawn('node', ['./bin/Hubot.mjs', '--disable-httpd']) : spawn('./bin/hubot', ['--disable-httpd']) let actual = {} const logMessages = [] hubot.stdout.on('data', (data) => { console.log(data.toString()) logMessages.push(data.toString()) }) hubot.stderr.on('data', (data) => { console.log(data.toString()) logMessages.push(data.toString()) }) const interval = setInterval(async () => { if (logMessages.some(m => m.includes('EADDRINUSE'))) { clearInterval(interval) assert.fail('Web service started when --disable-httpd was passed') done() } if (logMessages.some(m => m.includes('No external-scripts.json found. Skipping'))) { clearInterval(interval) try { const response = await fetch('http://localhost:8080') actual = await response.text() } catch (e) { actual = e } finally { hubot.kill() } console.log(actual) assert.ok(actual instanceof TypeError) assert.deepEqual(actual.message, 'fetch failed', 'this is an expected failure since the web service should not be running') done() } }, 60) }) }) ================================================ FILE: test/Listener_test.mjs ================================================ 'use strict' import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { EnterMessage, TextMessage, Listener, TextListener, Response, User, Middleware } from '../index.mjs' describe('Listener', () => { const robot = { // Re-throw AssertionErrors for clearer test failures emit (name, err, response) { if (err.constructor.name === 'AssertionError') { return process.nextTick(() => { throw err }) } }, // Ignore log messages logger: { debug () {}, error (...args) { // console.error(...args) } }, // Why is this part of the Robot object?? Response } const user = new User({ id: 1, name: 'hubottester', room: '#mocha' }) describe('Unit Tests', () => { describe('#call', () => { it('calls the matcher', async () => { const testMessage = new TextMessage(user, 'message') const testMatcher = message => { assert.deepEqual(message, testMessage) return true } const middleware = new Middleware(robot) middleware.register(async context => { assert.deepEqual(context.listener, testListener) }) const testListener = new Listener(robot, testMatcher, async response => true) await testListener.call(testMessage, middleware) }) it('the response object should have the match results so listeners can have access to it', async () => { const matcherResult = {} const testMatcher = message => { return matcherResult } const testMessage = new TextMessage(user, 'response should have match') const listenerCallback = response => assert.deepEqual(response.match, matcherResult) const testListener = new Listener(robot, testMatcher, listenerCallback) await testListener.call(testMessage, null) }) describe('if the matcher returns true', () => { const createListener = cb => { return new Listener(robot, () => true, cb) } it('executes the listener callback', async () => { const listenerCallback = async response => { assert.deepEqual(response.message, testMessage) } const testMessage = {} const testListener = createListener(listenerCallback) await testListener.call(testMessage, async (_) => {}) }) it('returns true', () => { const testMessage = {} const testListener = createListener(() => {}) const result = testListener.call(testMessage) assert.ok(result) }) it('calls the provided callback with true', (t, done) => { const testMessage = {} const testListener = createListener(() => {}) testListener.call(testMessage, async result => { assert.ok(result) done() }) }) it('calls the provided callback after the function returns', (t, done) => { const testMessage = {} const testListener = createListener(() => {}) let finished = false testListener.call(testMessage, async result => { assert.ok(finished) done() }) finished = true }) it('handles uncaught errors from the listener callback', async () => { const testMessage = {} const theError = new Error() const listenerCallback = async response => { throw theError } robot.emit = (name, err, response) => { assert.equal(name, 'error') assert.deepEqual(err, theError) assert.deepEqual(response.message, testMessage) } const testListener = createListener(listenerCallback) await testListener.call(testMessage, async response => {}) }) it('calls the provided callback with true if there is an error thrown by the listener callback', (t, done) => { const testMessage = {} const theError = new Error() const listenerCallback = async response => { throw theError } const testListener = createListener(listenerCallback) testListener.call(testMessage, async result => { assert.ok(result) done() }) }) it('calls the listener callback with a Response that wraps the Message', async () => { const testMessage = {} const listenerCallback = async response => { assert.deepEqual(response.message, testMessage) } const testListener = createListener(listenerCallback) await testListener.call(testMessage, async response => {}) }) it('passes through the provided middleware stack', async () => { const testMessage = {} const testListener = createListener(async () => {}) const testMiddleware = { execute (context, next, done) { assert.deepEqual(context.listener, testListener) assert.ok(context.response instanceof Response) assert.deepEqual(context.response.message, testMessage) assert.ok(typeof next === 'function') assert.ok(typeof done === 'function') } } await testListener.call(testMessage, testMiddleware) }) it('executes the listener callback if middleware succeeds', async () => { let wasCalled = false const listenerCallback = async () => { wasCalled = true } const testMessage = {} const testListener = createListener(listenerCallback) await testListener.call(testMessage, async result => { assert.ok(result) }) assert.deepEqual(wasCalled, true) }) it('does not execute the listener callback if middleware fails', async () => { let wasCalled = false const listenerCallback = async () => { wasCalled = true } const testMessage = {} const testListener = createListener(listenerCallback) const testMiddleware = { async execute (context) { return false } } await testListener.call(testMessage, testMiddleware, async result => { assert.ok(result) }) assert.deepEqual(wasCalled, false) }) }) describe('if the matcher returns false', () => { const createListener = cb => { return new Listener(robot, () => false, cb) } it('does not execute the listener callback', async () => { let wasCalled = false const listenerCallback = async () => { wasCalled = true } const testMessage = {} const testListener = createListener(listenerCallback) await testListener.call(testMessage, async context => { assert.deepEqual(wasCalled, false) }) }) it('returns null', async () => { const testMessage = {} const testListener = createListener(async () => {}) const result = await testListener.call(testMessage) assert.deepEqual(result, null) }) it('returns null because there is no matched listener', async () => { const testMessage = {} const testListener = createListener(async () => {}) const middleware = context => { throw new Error('Should not be called') } const result = await testListener.call(testMessage, middleware) assert.deepEqual(result, null) }) }) }) describe('#constructor', () => { it('requires a matcher', () => { assert.throws(() => { return new Listener(robot, undefined, {}, async () => {}) }, Error) }) it('requires a callback', () => { // No options assert.throws(() => { return new Listener(robot, () => {}) }, Error) // With options assert.throws(() => { return new Listener(robot, () => {}, {}) }, Error) }) it('gracefully handles missing options', () => { const testMatcher = () => {} const listenerCallback = async () => {} const testListener = new Listener(robot, testMatcher, listenerCallback) // slightly brittle because we are testing for the default options Object assert.deepEqual(testListener.options, { id: null }) assert.deepEqual(testListener.callback, listenerCallback) }) it('gracefully handles a missing ID (set to null)', () => { const testMatcher = () => {} const listenerCallback = async () => {} const testListener = new Listener(robot, testMatcher, {}, listenerCallback) assert.deepEqual(testListener.options.id, null) }) }) describe('TextListener', () => describe('#matcher', () => { it('matches TextMessages', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'test') testMessage.match = regex => { assert.deepEqual(regex, testRegex) return true } const testRegex = /test/ const testListener = new TextListener(robot, testRegex, callback) const result = testListener.matcher(testMessage) assert.ok(result) }) it('does not match EnterMessages', () => { const callback = async () => {} const testMessage = new EnterMessage(user) const testRegex = /test/ const testListener = new TextListener(robot, testRegex, callback) const result = testListener.matcher(testMessage) assert.deepEqual(result, undefined) }) it('matches non-TextMessage objects with a match function (duck-typing regression test)', () => { const callback = async () => {} const testRegex = /test/ // Simulate a message from a linked module that isn't an instanceof TextMessage const nonTextMessage = { user: 'testuser', text: 'test message', match (regex) { assert.deepEqual(regex, testRegex) return true } } const testListener = new TextListener(robot, testRegex, callback) const result = testListener.matcher(nonTextMessage) assert.ok(result) }) }) ) }) }) ================================================ FILE: test/Message_test.mjs ================================================ 'use strict' import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { User, Message, TextMessage } from '../index.mjs' describe('Message', () => { const user = new User({ id: 1, name: 'hubottester', room: '#mocha' }) describe('Unit Tests', () => { describe('#finish', () => it('marks the message as done', () => { const testMessage = new Message(user) assert.deepEqual(testMessage.done, false) testMessage.finish() assert.deepEqual(testMessage.done, true) }) ) describe('TextMessage', () => describe('#match', () => it('should perform standard regex matching', () => { const testMessage = new TextMessage(user, 'message123') assert.equal(testMessage.match(/^message123$/)[0], 'message123') assert.deepEqual(testMessage.match(/^does-not-match$/), null) }) ) ) }) }) ================================================ FILE: test/Middleware_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { Robot, TextMessage, Response, Middleware } from '../index.mjs' import mockAdapter from './fixtures/MockAdapter.mjs' describe('Middleware', () => { describe('Unit Tests', () => { let robot = null let middleware = null beforeEach(() => { robot = { emit () {} } middleware = new Middleware(robot) }) describe('#execute', () => { it('executes synchronous middleware', async () => { let wasCalled = false const testMiddleware = async context => { wasCalled = true return true } middleware.register(testMiddleware) await middleware.execute({}) assert.deepEqual(wasCalled, true) }) it('executes all registered middleware in definition order', async () => { const middlewareExecution = [] const testMiddlewareA = async context => { middlewareExecution.push('A') } const testMiddlewareB = async context => { middlewareExecution.push('B') } middleware.register(testMiddlewareA) middleware.register(testMiddlewareB) await middleware.execute({}) assert.deepEqual(middlewareExecution, ['A', 'B']) }) describe('error handling', () => { it('does not execute subsequent middleware after the error is thrown', async () => { const middlewareExecution = [] const testMiddlewareA = async context => { middlewareExecution.push('A') } const testMiddlewareB = async context => { middlewareExecution.push('B') throw new Error() } const testMiddlewareC = async context => { middlewareExecution.push('C') } middleware.register(testMiddlewareA) middleware.register(testMiddlewareB) middleware.register(testMiddlewareC) await middleware.execute({}) assert.deepEqual(middlewareExecution, ['A', 'B']) }) }) }) describe('#register', () => { it('adds to the list of middleware', () => { const testMiddleware = async context => {} middleware.register(testMiddleware) assert.ok(middleware.stack.includes(testMiddleware)) }) it('validates the arity of middleware', () => { const testMiddleware = async (context, next, done, extra) => {} assert.throws(() => middleware.register(testMiddleware), 'Incorrect number of arguments') }) }) }) // Per the documentation in docs/scripting.md // Any new fields that are exposed to middleware should be explicitly // tested for. describe('Public Middleware APIs', () => { let robot = null let user = null let testListener = null let testMessage = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run // Re-throw AssertionErrors for clearer test failures robot.on('error', function (err, response) { if (__guard__(err != null ? err.constructor : undefined, x => x.name) === 'AssertionError') { process.nextTick(() => { throw err }) } }) user = robot.brain.userForId('1', { name: 'hubottester', room: '#mocha' }) testMessage = new TextMessage(user, 'message123') robot.hear(/^message123$/, async response => {}) testListener = robot.listeners[0] }) afterEach(() => { robot.shutdown() }) describe('listener middleware context', () => { describe('listener', () => { it('is the listener object that matched, has metadata in options object with id', async () => { robot.listenerMiddleware(async context => { assert.deepEqual(context.listener, testListener) assert.ok(context.listener.options) assert.deepEqual(context.listener.options.id, null) return true }) await robot.receive(testMessage) }) }) describe('response', () => it('is a Response that wraps the message', async () => { robot.listenerMiddleware(async context => { assert.ok(context.response instanceof Response) assert.ok(context.response.message) assert.deepEqual(context.response.message, testMessage) return true }) await robot.receive(testMessage) }) ) }) describe('receive middleware context', () => { describe('response', () => { it('is a match-less Response object', async () => { robot.receiveMiddleware(async context => { assert.ok(context.response instanceof Response) assert.ok(context.response.message) assert.deepEqual(context.response.message, testMessage) return true }) await robot.receive(testMessage) }) }) }) }) }) function __guard__ (value, transform) { return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined } ================================================ FILE: test/OptParse-test.mjs ================================================ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import OptParse from '../src/OptParse.mjs' describe('CLI Argument Parsing', () => { it('should parse arguments into options', () => { const switches = [ ['-a', '--adapter HUBOT_ADAPTER', 'The Adapter to use, e.g. "Shell" (to load the default hubot Shell adapter)'], ['-f', '--file HUBOT_FILE', 'Path to adapter file, e.g. "./adapters/CustomAdapter.mjs"'], ['-c', '--create HUBOT_CREATE', 'Create a deployable hubot'], ['-d', '--disable-httpd HUBOT_HTTPD', 'Disable the HTTP server'], ['-h', '--help', 'Display the help information'], ['-l', '--alias HUBOT_ALIAS', "Enable replacing the robot's name with alias"], ['-n', '--name HUBOT_NAME', 'The name of the robot in chat'], ['-r', '--require PATH', 'Alternative scripts path'], ['-t', '--config-check', "Test hubot's config to make sure it won't fail at startup"], ['-v', '--version', 'Displays the version of hubot installed'] ] const options = { adapter: null, alias: false, create: false, enableHttpd: true, scripts: [], name: 'Hubot', file: null, configCheck: false } const Parser = new OptParse(switches) Parser.on('adapter', (opt, value) => { options.adapter = value }) Parser.on('disable-httpd', (opt, value) => { options.enableHttpd = false }) Parser.on('alias', (opt, value) => { options.alias = value }) Parser.parse(['-a', 'Shell', '-d', '--alias', 'bot']) assert.deepEqual(options.adapter, 'Shell') assert.deepEqual(options.enableHttpd, false) assert.deepEqual(options.alias, 'bot') }) }) ================================================ FILE: test/Robot_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import path from 'node:path' import { Robot, CatchAllMessage, EnterMessage, LeaveMessage, TextMessage, TopicMessage, User, Response } from '../index.mjs' import mockAdapter from './fixtures/MockAdapter.mjs' describe('Robot', () => { describe('#http', () => { let robot = null beforeEach(() => { robot = new Robot(null, false, 'TestHubot') }) afterEach(() => { robot.shutdown() }) it('API', () => { const agent = {} const httpClient = robot.http('http://example.com', { agent }) assert.ok(httpClient.get) assert.ok(httpClient.post) }) it('passes options through to the ScopedHttpClient', () => { const agent = {} const httpClient = robot.http('http://example.com', { agent }) assert.deepEqual(httpClient.options.agent, agent) }) it('sets a user agent', () => { const httpClient = robot.http('http://example.com') assert.ok(httpClient.options.headers['User-Agent'].indexOf('Hubot') > -1) }) it('meges global http options', () => { const agent = {} robot.globalHttpOptions = { agent } const httpClient = robot.http('http://localhost') assert.deepEqual(httpClient.options.agent, agent) }) it('local options override global http options', () => { const agentA = {} const agentB = {} robot.globalHttpOptions = { agent: agentA } const httpClient = robot.http('http://localhost', { agent: agentB }) assert.deepEqual(httpClient.options.agent, agentB) }) it('builds the url correctly from a string', () => { const httpClient = robot.http('http://localhost') const options = httpClient.buildOptions('http://localhost:3001') assert.equal(options.host, 'localhost:3001') assert.equal(options.pathname, '/') assert.equal(options.protocol, 'http:') assert.equal(options.port, '3001') }) }) describe('#respondPattern', () => { let robot = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot', 't-bot') await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() }) it('matches messages starting with robot\'s name', () => { const testMessage = robot.name + 'message123' const testRegex = /(.*)/ const pattern = robot.respondPattern(testRegex) assert.match(testMessage, pattern) const match = testMessage.match(pattern)[1] assert.equal(match, 'message123') }) it("matches messages starting with robot's alias", () => { const testMessage = robot.alias + 'message123' const testRegex = /(.*)/ const pattern = robot.respondPattern(testRegex) assert.match(testMessage, pattern) const match = testMessage.match(pattern)[1] assert.equal(match, 'message123') }) it('does not match unaddressed messages', () => { const testMessage = 'message123' const testRegex = /(.*)/ const pattern = robot.respondPattern(testRegex) assert.doesNotMatch(testMessage, pattern) }) it('matches properly when name is substring of alias', () => { robot.name = 'Meg' robot.alias = 'Megan' const testMessage1 = robot.name + ' message123' const testMessage2 = robot.alias + ' message123' const testRegex = /(.*)/ const pattern = robot.respondPattern(testRegex) assert.match(testMessage1, pattern) const match1 = testMessage1.match(pattern)[1] assert.equal(match1, 'message123') assert.match(testMessage2, pattern) const match2 = testMessage2.match(pattern)[1] assert.equal(match2, 'message123') }) it('matches properly when alias is substring of name', () => { robot.name = 'Megan' robot.alias = 'Meg' const testMessage1 = robot.name + ' message123' const testMessage2 = robot.alias + ' message123' const testRegex = /(.*)/ const pattern = robot.respondPattern(testRegex) assert.match(testMessage1, pattern) const match1 = testMessage1.match(pattern)[1] assert.equal(match1, 'message123') assert.match(testMessage2, pattern) const match2 = testMessage2.match(pattern)[1] assert.equal(match2, 'message123') }) }) describe('Listening API', () => { let robot = null beforeEach(() => { robot = new Robot(null, false, 'TestHubot') }) afterEach(() => { robot.shutdown() }) it('#listen: registers a new listener directly', () => { assert.equal(robot.listeners.length, 0) robot.listen(() => {}, () => {}) assert.equal(robot.listeners.length, 1) }) it('#hear: registers a new listener directly', () => { assert.equal(robot.listeners.length, 0) robot.hear(/.*/, () => {}) assert.equal(robot.listeners.length, 1) }) it('#respond: registers a new listener using respond', () => { assert.equal(robot.listeners.length, 0) robot.respond(/.*/, () => {}) assert.equal(robot.listeners.length, 1) }) it('#enter: registers a new listener using listen', () => { assert.equal(robot.listeners.length, 0) robot.enter(() => {}) assert.equal(robot.listeners.length, 1) }) it('#leave: registers a new listener using listen', () => { assert.equal(robot.listeners.length, 0) robot.leave(() => {}) assert.equal(robot.listeners.length, 1) }) it('#topic: registers a new listener using listen', () => { assert.equal(robot.listeners.length, 0) robot.topic(() => {}) assert.equal(robot.listeners.length, 1) }) it('#catchAll: registers a new listener using listen', () => { assert.equal(robot.listeners.length, 0) robot.catchAll(() => {}) assert.equal(robot.listeners.length, 1) }) }) describe('#receive', () => { let robot = null let user = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() user = new User('1', { name: 'node', room: '#test' }) }) afterEach(() => { robot.shutdown() }) it('calls all registered listeners', async () => { // Need to use a real Message so that the CatchAllMessage constructor works const testMessage = new TextMessage(user, 'message123') let counter = 0 const listener = async message => { counter++ } robot.listen(() => true, null, listener) robot.listen(() => true, null, listener) robot.listen(() => true, null, listener) robot.listen(() => true, null, listener) await robot.receive(testMessage) assert.equal(counter, 4) }) it('sends a CatchAllMessage if no listener matches', async () => { const testMessage = new TextMessage(user, 'message123') robot.listeners = [] let actual = null robot.catchAll(async (message) => { actual = message }) await robot.receive(testMessage) assert.ok(actual.message instanceof CatchAllMessage) assert.deepEqual(actual.message.message, testMessage) }) it('calls the catch-all listener with a Response object', async () => { const testMessage = new TextMessage(user, 'message123') const listenerCallback = async () => { assert.fail('Should not have called listener') } robot.hear(/^no-matches$/, listenerCallback) let actual = null robot.catchAll(async response => { response.reply('caught by catchAll') actual = response }) await robot.receive(testMessage) assert.ok(actual instanceof Response) }) it('does not trigger a CatchAllMessage if a listener matches', async () => { const testMessage = new TextMessage(user, 'message123') const matchingListener = async response => { assert.deepEqual(response.message, testMessage) } robot.listen(() => true, null, matchingListener) robot.catchAll(null, () => { throw new Error('Should not have triggered catchAll') }) await robot.receive(testMessage) }) it('stops processing if a listener marks the message as done', async () => { const testMessage = new TextMessage(user, 'message123') let spyCalled = false const matchingListener = async response => { response.message.finish() assert.equal(response.message.text, testMessage.text) assert.equal(response.message.user.id, testMessage.user.id) } const listenerSpy = async message => { spyCalled = true assert.fail('Should not have triggered listener') } robot.listen(() => true, null, matchingListener) robot.listen(() => true, null, listenerSpy) await robot.receive(testMessage) assert.equal(spyCalled, false) }) it('gracefully handles listener uncaughtExceptions (move on to next listener)', async () => { const testMessage = {} const theError = new Error('Expected error') const badListener = async () => { throw theError } let goodListenerCalled = false const goodListener = async message => { goodListenerCalled = true } robot.listen(() => true, null, badListener) robot.listen(() => true, null, goodListener) robot.on('error', (err, response) => { assert.deepEqual(err, theError) assert.deepEqual(response.message, testMessage) }) await robot.receive(testMessage) assert.ok(goodListenerCalled) }) }) describe('#load', () => { let robot = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() process.removeAllListeners() }) it('should load scripts in the same order that they are in the folder', async () => { await robot.load(path.resolve('./test/ordered-scripts')) assert.deepEqual(robot.loadedScripts, ['01-First', '02-Second', '03-Third']) }) }) describe('#loadFile', () => { let robot = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() process.removeAllListeners() }) it('should require the specified file', async () => { await robot.loadFile(path.resolve('./test/fixtures'), 'TestScript.js') assert.deepEqual(robot.hasLoadedTestJsScript, true) }) it('should load an .mjs file', async () => { await robot.loadFile(path.resolve('./test/fixtures'), 'TestScript.mjs') assert.deepEqual(robot.hasLoadedTestMjsScript, true) }) it('should load an .ts file', async () => { await robot.loadFile(path.resolve('./test/fixtures'), 'TestScript.ts') assert.deepEqual(robot.hasLoadedTestTsScript, true) }) describe('proper script', () => { it('should parse the script documentation', async () => { await robot.loadFile(path.resolve('./test/fixtures'), 'TestScript.js') assert.deepEqual(robot.helpCommands(), ['hubot test - Responds with a test response']) }) }) describe('non-Function script', () => { it('logs a warning for a .js file that does not export the correct API', async () => { let wasCalled = false robot.logger.warn = (...args) => { wasCalled = true assert.ok(args) } await robot.loadFile(path.resolve('./test/fixtures'), 'TestScriptIncorrectApi.js') assert.deepEqual(wasCalled, true) }) it('logs a warning for a .mjs file that does not export the correct API', async () => { let wasCalled = false robot.logger.warn = (...args) => { wasCalled = true assert.ok(args) } await robot.loadFile(path.resolve('./test/fixtures'), 'TestScriptIncorrectApi.mjs') assert.deepEqual(wasCalled, true) }) }) describe('unsupported file extension', () => { it('should not be loaded by the Robot', async () => { let wasCalled = false robot.logger.debug = (...args) => { wasCalled = true assert.match(args[0], /unsupported file type/) } await robot.loadFile(path.resolve('./test/fixtures'), 'unsupported.yml') assert.deepEqual(wasCalled, true) }) }) }) describe('Sending API', () => { let robot = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() }) it('#send: delegates to adapter "send" with proper context', async () => { let wasCalled = false robot.adapter.send = async (envelop, ...strings) => { wasCalled = true assert.deepEqual(strings, ['test message'], 'The strings should be passed through.') } await robot.send({}, 'test message') assert.deepEqual(wasCalled, true) }) it('#reply: delegates to adapter "reply" with proper context', async () => { let wasCalled = false robot.adapter.reply = async (envelop, ...strings) => { assert.deepEqual(strings, ['test message'], 'The strings should be passed through.') wasCalled = true } await robot.reply({}, 'test message') assert.deepEqual(wasCalled, true) }) it('#messageRoom: delegates to adapter "send" with proper context', async () => { let wasCalled = false robot.adapter.send = async (envelop, ...strings) => { assert.equal(envelop.room, 'testRoom', 'The room should be passed through.') assert.deepEqual(strings, ['messageRoom test'], 'The strings should be passed through.') wasCalled = true } await robot.messageRoom('testRoom', 'messageRoom test') assert.deepEqual(wasCalled, true) }) }) describe('Listener Registration', () => { let robot = null let user = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() user = new User('1', { name: 'node', room: '#test' }) }) afterEach(() => { robot.shutdown() }) it('#listen: forwards the matcher, options, and callback to Listener', () => { const callback = async () => {} const matcher = () => {} const options = {} robot.listen(matcher, options, callback) const testListener = robot.listeners[0] assert.deepEqual(testListener.matcher, matcher) assert.deepEqual(testListener.callback, callback) assert.deepEqual(testListener.options, options) }) it('#hear: matches TextMessages', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'message123') const testRegex = /^message123$/ robot.hear(testRegex, callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.ok(result) }) it('does not match EnterMessages', () => { const callback = async () => {} const testMessage = new EnterMessage(user) const testRegex = /.*/ robot.hear(testRegex, callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, undefined) }) it('#respond: matches TextMessages addressed to the robot', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'TestHubot message123') const testRegex = /message123$/ robot.respond(testRegex, callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.ok(result) }) it('does not match EnterMessages', () => { const callback = async () => {} const testMessage = new EnterMessage(user) const testRegex = /.*/ robot.respond(testRegex, callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, undefined) }) it('#enter: matches EnterMessages', () => { const callback = async () => {} const testMessage = new EnterMessage(user) robot.enter(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.ok(result) }) it('does not match TextMessages', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'message123') robot.enter(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, false) }) it('#leave: matches LeaveMessages', () => { const callback = async () => {} const testMessage = new LeaveMessage(user) robot.leave(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.ok(result) }) it('does not match TextMessages', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'message123') robot.leave(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, false) }) it('#topic: matches TopicMessages', () => { const callback = async () => {} const testMessage = new TopicMessage(user) robot.topic(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, true) }) it('does not match TextMessages', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'message123') robot.topic(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, false) }) it('#catchAll: matches CatchAllMessages', () => { const callback = async () => {} const testMessage = new CatchAllMessage(new TextMessage(user, 'message123')) robot.catchAll(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, true) }) it('does not match TextMessages', () => { const callback = async () => {} const testMessage = new TextMessage(user, 'message123') robot.catchAll(callback) const testListener = robot.listeners[0] const result = testListener.matcher(testMessage) assert.deepEqual(result, false) }) }) describe('Message Processing', () => { let robot = null let user = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() user = new User('1', { name: 'node', room: '#test' }) }) afterEach(() => { robot.shutdown() }) it('calls a matching listener', async () => { const testMessage = new TextMessage(user, 'message123') robot.hear(/^message123$/, async response => { assert.deepEqual(response.message, testMessage) }) await robot.receive(testMessage) }) it('calls multiple matching listeners', async () => { const testMessage = new TextMessage(user, 'message123') let listenersCalled = 0 const listenerCallback = async response => { assert.deepEqual(response.message, testMessage) listenersCalled++ } robot.hear(/^message123$/, listenerCallback) robot.hear(/^message123$/, listenerCallback) await robot.receive(testMessage) assert.equal(listenersCalled, 2) }) it('calls the catch-all listener if no listeners match', async () => { const testMessage = new TextMessage(user, 'message123') const listenerCallback = async () => { assert.fail('Should not have called listener') } robot.hear(/^no-matches$/, listenerCallback) robot.catchAll(async response => { assert.deepEqual(response.message.message, testMessage) }) await robot.receive(testMessage) }) it('does not call the catch-all listener if any listener matched', async () => { const testMessage = new TextMessage(user, 'message123') let counter = 0 const listenerCallback = async () => { counter++ } robot.hear(/^message123$/, listenerCallback) const catchAllCallback = async () => { assert.fail('Should not have been called') } robot.catchAll(catchAllCallback) await robot.receive(testMessage) assert.equal(counter, 1) }) it('stops processing if message.finish() is called synchronously', async () => { const testMessage = new TextMessage(user, 'message123') robot.hear(/^message123$/, async response => response.message.finish()) let wasCalled = false const listenerCallback = async () => { wasCalled = true assert.fail('Should not have been called') } robot.hear(/^message123$/, listenerCallback) await robot.receive(testMessage) assert.equal(wasCalled, false) }) it('calls non-TextListener objects', async () => { const testMessage = new EnterMessage(user) robot.enter(async response => { assert.deepEqual(response.message, testMessage) }) await robot.receive(testMessage) }) it('gracefully handles hearer uncaughtExceptions (move on to next hearer)', async () => { const testMessage = new TextMessage(user, 'message123') const theError = new Error('Expected error to be thrown') robot.hear(/^message123$/, async () => { throw theError }) let goodListenerCalled = false robot.hear(/^message123$/, async response => { goodListenerCalled = true }) robot.on('error', (err, response) => { assert.deepEqual(err, theError) assert.deepEqual(response.message, testMessage) }) await robot.receive(testMessage) assert.deepEqual(goodListenerCalled, true) }) }) describe('Listener Middleware', () => { let robot = null let user = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() user = new User('1', { name: 'node', room: '#test' }) }) afterEach(() => { robot.shutdown() }) it('allows listener callback execution', async () => { let wasCalled = false const listenerCallback = async () => { wasCalled = true } robot.hear(/^message123$/, listenerCallback) robot.listenerMiddleware(async context => true) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(wasCalled, true) }) it('can block listener callback execution', async () => { let wasCalled = false const listenerCallback = async () => { wasCalled = true assert.fail('Should not have been called') } robot.hear(/^message123$/, listenerCallback) robot.listenerMiddleware(async context => false) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(wasCalled, false) }) it('receives the correct arguments', async () => { robot.hear(/^message123$/, async () => {}) const testListener = robot.listeners[0] const testMessage = new TextMessage(user, 'message123') robot.listenerMiddleware(async context => { // Escape middleware error handling for clearer test failures assert.deepEqual(context.listener, testListener) assert.deepEqual(context.response.message, testMessage) return true }) await robot.receive(testMessage) }) it('executes middleware in order of definition', async () => { const execution = [] const testMiddlewareA = async context => { execution.push('middlewareA') } const testMiddlewareB = async context => { execution.push('middlewareB') } robot.listenerMiddleware(testMiddlewareA) robot.listenerMiddleware(testMiddlewareB) robot.hear(/^message123$/, () => execution.push('listener')) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) execution.push('done') assert.deepEqual(execution, [ 'middlewareA', 'middlewareB', 'listener', 'done' ]) }) }) describe('Receive Middleware', () => { let robot = null let user = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() await robot.run() user = new User('1', { name: 'node', room: '#test' }) }) afterEach(() => { robot.shutdown() }) it('fires for all messages, including non-matching ones', async () => { let middlewareWasCalled = false const middlewareSpy = async () => { middlewareWasCalled = true } let wasCalled = false const listenerCallback = async () => { wasCalled = true assert.fail('Should not have been called') } robot.hear(/^message123$/, listenerCallback) robot.receiveMiddleware(async context => { middlewareSpy() }) const testMessage = new TextMessage(user, 'not message 123') await robot.receive(testMessage) assert.deepEqual(wasCalled, false) assert.deepEqual(middlewareWasCalled, true) }) it('can block listener execution', async () => { let middlewareWasCalled = false const middlewareSpy = async () => { middlewareWasCalled = true } let wasCalled = false const listenerCallback = async () => { wasCalled = true assert.fail('Should not have been called') } robot.hear(/^message123$/, listenerCallback) robot.receiveMiddleware(async context => { middlewareSpy() return false }) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(wasCalled, false) assert.deepEqual(middlewareWasCalled, true) }) it('receives the correct arguments', async () => { robot.hear(/^message123$/, () => {}) const testMessage = new TextMessage(user, 'message123') robot.receiveMiddleware(async context => { assert.deepEqual(context.response.message, testMessage) }) await robot.receive(testMessage) }) it('executes receive middleware in order of definition', async () => { const execution = [] const testMiddlewareA = async context => { execution.push('middlewareA') } const testMiddlewareB = async context => { execution.push('middlewareB') } robot.receiveMiddleware(testMiddlewareA) robot.receiveMiddleware(testMiddlewareB) robot.hear(/^message123$/, () => execution.push('listener')) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) execution.push('done') assert.deepEqual(execution, [ 'middlewareA', 'middlewareB', 'listener', 'done' ]) }) it('allows editing the message portion of the given response', async () => { const testMiddlewareA = async context => { context.response.message.text = 'foobar' } const testMiddlewareB = async context => { assert.equal(context.response.message.text, 'foobar') } robot.receiveMiddleware(testMiddlewareA) robot.receiveMiddleware(testMiddlewareB) let wasCalled = false const testCallback = () => { wasCalled = true } // We'll never get to this if testMiddlewareA has not modified the message. robot.hear(/^foobar$/, testCallback) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(wasCalled, true) }) }) describe('Response Middleware', () => { let robot = null let user = null beforeEach(async () => { robot = new Robot(mockAdapter, false, 'TestHubot') user = new User('1', { name: 'node', room: '#test' }) robot.alias = 'Hubot' await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() }) it('executes response middleware in order', async () => { let wasCalled = false robot.adapter.send = async (envelope, ...strings) => { assert.deepEqual(strings, ['replaced bar-foo, sir, replaced bar-foo.']) wasCalled = true } robot.hear(/^message123$/, async response => await response.send('foobar, sir, foobar.')) robot.responseMiddleware(async context => { context.strings[0] = context.strings[0].replace(/foobar/g, 'barfoo') }) robot.responseMiddleware(async context => { context.strings[0] = context.strings[0].replace(/barfoo/g, 'replaced bar-foo') }) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(wasCalled, true) }) it('allows replacing outgoing strings', async () => { let wasCalled = false robot.adapter.send = async (envelope, ...strings) => { wasCalled = true assert.deepEqual(strings, ['whatever I want.']) } robot.hear(/^message123$/, async response => response.send('foobar, sir, foobar.')) robot.responseMiddleware(async context => { context.strings = ['whatever I want.'] }) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(wasCalled, true) }) it('marks plaintext as plaintext', async () => { robot.adapter.send = async (envelope, ...strings) => { assert.deepEqual(strings, ['foobar, sir, foobar.']) } robot.adapter.play = async (envelope, ...strings) => { assert.deepEqual(strings, ['good luck with that']) } robot.hear(/^message123$/, async response => await response.send('foobar, sir, foobar.')) robot.hear(/^message456$/, async response => await response.play('good luck with that')) let method let plaintext robot.responseMiddleware(async context => { method = context.method plaintext = context.plaintext }) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(plaintext, true) assert.equal(method, 'send') const testMessage2 = new TextMessage(user, 'message456') await robot.receive(testMessage2) assert.deepEqual(plaintext, undefined) assert.equal(method, 'play') }) it('does not send trailing functions to middleware', async () => { let wasCalled = false robot.adapter.send = async (envelope, ...strings) => { wasCalled = true assert.deepEqual(strings, ['foobar, sir, foobar.']) } let asserted = false robot.hear(/^message123$/, async response => await response.send('foobar, sir, foobar.')) robot.responseMiddleware(async context => { // We don't send the callback function to middleware, so it's not here. assert.deepEqual(context.strings, ['foobar, sir, foobar.']) assert.equal(context.method, 'send') asserted = true }) const testMessage = new TextMessage(user, 'message123') await robot.receive(testMessage) assert.deepEqual(asserted, true) assert.deepEqual(wasCalled, true) }) }) describe('Robot ES6', () => { let robot = null beforeEach(async () => { robot = new Robot('MockAdapter', false, 'TestHubot') robot.alias = 'Hubot' await robot.loadAdapter('./test/fixtures/MockAdapter.mjs') await robot.loadFile(path.resolve('./test/fixtures/'), 'TestScript.js') await robot.run() }) afterEach(() => { robot.shutdown() }) it('should load an ES6 module adapter from a file', async () => { const { MockAdapter } = await import('./fixtures/MockAdapter.mjs') assert.ok(robot.adapter instanceof MockAdapter) assert.equal(robot.adapter.name, 'MockAdapter') }) it('should respond to a message', async () => { const sent = async (envelop, strings) => { assert.deepEqual(strings, ['test response']) } robot.adapter.on('send', sent) await robot.receive(new TextMessage('tester', 'hubot test')) }) }) describe('Robot Defaults', () => { let robot = null beforeEach(async () => { robot = new Robot(null, false, 'TestHubot') robot.alias = 'Hubot' await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() process.removeAllListeners() }) it('should load the builtin Shell adapter by default', async () => { assert.equal(robot.adapter.name, 'Shell') }) }) describe('Robot HTTP Service', () => { it('should start a web service', async () => { process.env.PORT = 0 const robot = new Robot(mockAdapter, true, 'TestHubot') await robot.loadAdapter() await robot.run() const port = robot.server.address().port const res = await fetch(`http://127.0.0.1:${port}/hubot/version`) assert.equal(res.status, 404) assert.match(await res.text(), /Cannot GET \/hubot\/version/ig) robot.shutdown() delete process.env.PORT }) }) }) ================================================ FILE: test/Shell_test.mjs ================================================ 'use strict' import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { Robot, TextMessage, User } from '../index.mjs' import stream from 'node:stream' import { writeFile, stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import path from 'node:path' describe('Shell history file test', () => { it('History file is > 1024 bytes when running does not throw an error', async () => { const robot = new Robot('Shell', false, 'TestHubot') robot.stdin = new stream.Readable() robot.stdin._read = () => {} const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const historyPath = path.join(__dirname, '..', '.hubot_history') await writeFile(historyPath, 'a'.repeat(1025)) await robot.loadAdapter() await robot.run() try { const fileInfo = await stat(historyPath) assert.ok(fileInfo.size <= 1024, 'History file should be less than or equal to 1024 bytes after running the robot') } catch (error) { assert.fail('Should not throw an error when reading history file') } finally { robot.shutdown() } }) }) describe('Shell Adapter Integration Test', () => { let robot = null beforeEach(async () => { robot = new Robot('Shell', false, 'TestHubot') robot.stdin = new stream.Readable() robot.stdin._read = () => {} await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() }) it('responds to a message that starts with the robot name', async () => { let wasCalled = false robot.respond(/helo/, async res => { wasCalled = true await res.reply('hello from the other side') }) robot.stdin.push(robot.name + ' helo\n') robot.stdin.push(null) await new Promise(resolve => setTimeout(resolve, 60)) assert.deepEqual(wasCalled, true) }) it('responds to a message without starting with the robot name', async () => { let wasCalled = false robot.respond(/helo/, async res => { wasCalled = true await res.reply('hello from the other side') }) robot.stdin.push('helo\n') robot.stdin.push(null) await new Promise(resolve => setTimeout(resolve, 60)) assert.deepEqual(wasCalled, true) }) it('shows prompt if nothing was entered', async () => { let wasCalled = false robot.respond(/\n/, async res => { wasCalled = true await res.reply('hello from the other side') }) robot.stdin.push('\n') robot.stdin.push(null) await new Promise(resolve => setTimeout(resolve, 60)) assert.deepEqual(wasCalled, false) }) it('shows prompt if only spaces were entered', async () => { let wasCalled = false robot.respond(/.*/, async res => { wasCalled = true await res.reply('hello from the other side') }) robot.stdin.push(' \n') robot.stdin.push(null) await new Promise(resolve => setTimeout(resolve, 60)) assert.deepEqual(wasCalled, false) }) it('shows prompt if only tabs were entered', async () => { let wasCalled = false robot.respond(/.*/, async res => { wasCalled = true await res.reply('hello from the other side') }) robot.stdin.push('\t\t\n') robot.stdin.push(null) await new Promise(resolve => setTimeout(resolve, 60)) assert.deepEqual(wasCalled, false) }) }) describe('Shell Adapter', () => { let robot = null beforeEach(async () => { robot = new Robot('Shell', false, 'TestHubot') robot.stdin = new stream.Readable() robot.stdin._read = () => {} await robot.loadAdapter() await robot.run() }) afterEach(() => { robot.shutdown() }) describe('Public API', () => { let adapter = null beforeEach(() => { adapter = robot.adapter }) it('assigns robot', () => { assert.deepEqual(adapter.robot, robot, 'The adapter should have a reference to the robot.') }) it('sends a message', async () => { const old = console.log let wasCalled = false console.log = (...args) => { console.log = old assert.deepEqual(args[0], '\x1b[1mhello\x1b[22m', 'Message should be outputed as bold to the console.') wasCalled = true } await adapter.send({ room: 'general' }, 'hello') assert.deepEqual(wasCalled, true) }) it('emotes a message', async () => { const old = console.log let wasCalled = false console.log = (...args) => { console.log = old assert.deepEqual(args[0], '\x1b[1m* hello\x1b[22m', 'Message should be bold and have an * in front.') wasCalled = true } await adapter.emote({ room: 'general' }, 'hello') assert.deepEqual(wasCalled, true) }) it('replies to a message', async () => { const old = console.log let wasCalled = false console.log = (...args) => { console.log = old assert.deepEqual(args[0], '\x1b[1mnode: hello\x1b[22m', 'The strings should be passed through.') wasCalled = true } await adapter.reply({ room: 'general', user: { name: 'node' } }, 'hello') assert.deepEqual(wasCalled, true) }) it('runs the adapter and emits connected', async () => { let wasCalled = false const connected = () => { adapter.off('connected', connected) assert.ok(true, 'The connected event should be emitted.') wasCalled = true } adapter.on('connected', connected) await adapter.run() assert.deepEqual(wasCalled, true) robot.shutdown() }) it('dispatches received messages to the robot', async () => { const message = new TextMessage(new User('node'), 'hello', 1) let wasCalled = false robot.receive = (msg) => { assert.deepEqual(msg, message, 'The message should be passed through.') wasCalled = true } await adapter.receive(message) assert.deepEqual(wasCalled, true) }) }) }) describe('Shell Adapter: Print human readable logging in the console when something is logged with robot.logger', async () => { it('setting HUBOT_LOG_LEVEL to debug prints debug and info log messages to the console', async () => { process.env.HUBOT_LOG_LEVEL = 'debug' const robot = new Robot('Shell', false, 'TestHubot') await robot.loadAdapter() await robot.run() const old = console.log const expected = { debug: false, info: false } console.log = (...args) => { old(...args) switch (true) { case args[0].includes('[debug]'): expected.debug = true break case args[0].includes('[info]'): expected.info = true break } } robot.logger.debug('should print debug message to console') robot.logger.info('should print info message to console') delete process.env.HUBOT_LOG_LEVEL console.log = old assert.deepEqual(expected, { debug: true, info: true }) robot.shutdown() }) it('setting HUBOT_LOG_LEVEL to error only prints error log messages to the console', async () => { process.env.HUBOT_LOG_LEVEL = 'error' const robot = new Robot('Shell', false, 'TestHubot') await robot.loadAdapter() await robot.run() const old = console.log const expected = { debug: false, info: false, error: false } console.log = (...args) => { old(...args) switch (true) { case args[0].includes('[debug]'): expected.debug = true break case args[0].includes('[info]'): expected.info = true break case args[0].includes('[error]'): expected.error = true break } } robot.logger.debug('should NOT print debug message to console') robot.logger.info('should NOT print info message to console') robot.logger.error('should print error message to console') delete process.env.HUBOT_LOG_LEVEL console.log = old assert.deepEqual(expected, { debug: false, info: false, error: true }) robot.shutdown() }) }) describe('Shell Adapter: Logger before adapter run', () => { it('does not throw when logging before the adapter initializes readline', async () => { const robot = new Robot('Shell', false, 'TestHubot') robot.stdin = new stream.Readable() robot.stdin._read = () => {} const originalLog = console.log const logMessages = [] console.log = (...args) => { logMessages.push(args[0]) } try { await assert.doesNotReject(async () => { // Before loadAdapter - uses default pino logger robot.logger.info('log before adapter load') await robot.loadAdapter() // After loadAdapter but before run - still uses pino logger (logger override happens in run()) await robot.logger.info('log after load before run') await robot.run() // After run - uses Shell adapter's custom logger with formatted output await robot.logger.info('log after run') }) // Verify that logging after run() uses the Shell adapter's custom formatted logger assert.ok(logMessages.some(msg => typeof msg === 'string' && msg.includes('[info]') && msg.includes('log after run')), 'Should use Shell adapter formatted logger after run()') } finally { console.log = originalLog robot.shutdown() } }) }) ================================================ FILE: test/User_test.mjs ================================================ 'use strict' import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { User } from '../index.mjs' describe('User', () => describe('new', function () { it('uses id as the default name', function () { const user = new User('hubot') assert.equal(user.name, 'hubot', 'User constructor should set name') }) it('sets attributes passed in', function () { const user = new User('hubot', { foo: 1, bar: 2 }) assert.equal(user.foo, 1, 'Passing an object with attributes in the User constructor should set those attributes on the instance.') assert.equal(user.bar, 2, 'Passing an object with attributes in the User constructor should set those attributes on the instance.') }) it('uses name attribute when passed in, not id', function () { const user = new User('hubot', { name: 'tobuh' }) assert.equal(user.name, 'tobuh', 'Passing a name attribute in the User constructor should set the name attribute on the instance.') }) }) ) ================================================ FILE: test/XampleTest.mjs ================================================ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' // Replace this with import { Robot } from 'hubot' import { Robot } from '../index.mjs' // You need a dummy adapter to test scripts import dummyRobot from './doubles/DummyAdapter.mjs' // Mocks Aren't Stubs // https://www.martinfowler.com/articles/mocksArentStubs.html describe('Xample testing Hubot scripts', () => { let robot = null beforeEach(async () => { process.env.EXPRESS_PORT = 0 robot = new Robot(dummyRobot, true, 'Dumbotheelephant') await robot.loadAdapter() await robot.run() await robot.loadFile('./test/scripts', 'Xample.mjs') }) afterEach(() => { delete process.env.EXPRESS_PORT robot.shutdown() }) it('should handle /helo request', async () => { const expected = "HELO World! I'm Dumbotheelephant." const response = await fetch(`http://localhost:${robot.server.address().port}/helo`) const actual = await response.text() assert.strictEqual(actual, expected) }) it('should reply with expected message', async () => { const expected = 'HELO World! I\'m Dumbotheelephant.' const user = robot.brain.userForId('test-user', { name: 'test user' }) let actual = '' robot.on('reply', (envelope, ...strings) => { actual = strings.join('') }) await robot.adapter.say(user, '@Dumbotheelephant helo', 'test-room') assert.strictEqual(actual, expected) }) it('should send message to the #general room', async () => { const expected = 'general' const user = robot.brain.userForId('test-user', { name: 'test user' }) let actual = '' robot.on('send', (envelope, ...strings) => { actual = envelope.room }) await robot.adapter.say(user, '@Dumbotheelephant helo room', 'general') assert.strictEqual(actual, expected) }) }) ================================================ FILE: test/doubles/DummyAdapter.mjs ================================================ 'use strict' // Replace this with import { Adapter, TextMessage } from 'hubot' import { Adapter, TextMessage } from '../../index.mjs' export class DummyAdapter extends Adapter { constructor (robot) { super(robot) this.name = 'DummyAdapter' this.messages = new Set() } async send (envelope, ...strings) { this.emit('send', envelope, ...strings) this.robot.emit('send', envelope, ...strings) } async reply (envelope, ...strings) { this.emit('reply', envelope, ...strings) this.robot.emit('reply', envelope, ...strings) } async topic (envelope, ...strings) { this.emit('topic', envelope, ...strings) this.robot.emit('topic', envelope, ...strings) } async play (envelope, ...strings) { this.emit('play', envelope, ...strings) this.robot.emit('play', envelope, ...strings) } run () { // This is required to get the scripts loaded this.emit('connected') } close () { this.emit('closed') } async say (user, message, room) { this.messages.add(message) user.room = room await this.robot.receive(new TextMessage(user, message)) } } export default { async use (robot) { return new DummyAdapter(robot) } } ================================================ FILE: test/fixtures/MockAdapter.mjs ================================================ 'use strict' import { Adapter } from '../../index.mjs' export class MockAdapter extends Adapter { constructor (robot) { super(robot) this.name = 'MockAdapter' } async send (envelope, ...strings) { this.emit('send', envelope, ...strings) } async reply (envelope, ...strings) { this.emit('reply', envelope, ...strings) } async topic (envelope, ...strings) { this.emit('topic', envelope, ...strings) } async play (envelope, ...strings) { this.emit('play', envelope, ...strings) } run () { // This is required to get the scripts loaded this.emit('connected') } close () { this.emit('closed') } } export default { use (robot) { return new MockAdapter(robot) } } ================================================ FILE: test/fixtures/TestScript.js ================================================ 'use strict' // Description: A test script for the robot to load // // Commands: // hubot test - Responds with a test response // module.exports = robot => { robot.hasLoadedTestJsScript = true robot.respond('test', async res => { await res.send('test response') }) } ================================================ FILE: test/fixtures/TestScript.mjs ================================================ 'use strict' // Description: A test .mjs script for the robot to load // // Commands: // hubot test mjs - Responds with a test response from a .mjs script // export default robot => { robot.hasLoadedTestMjsScript = true robot.respond(/test$/, async res => { await res.reply('test response from .mjs script') }) } ================================================ FILE: test/fixtures/TestScript.ts ================================================ 'use strict' // Description: A test .ts script for the robot to load // // Commands: // hubot test ts - Responds with a test response from a .ts script // export default robot => { robot.hasLoadedTestTsScript = true robot.respond(/test$/, async res => { await res.reply('test response from .ts script') }) } ================================================ FILE: test/fixtures/TestScriptIncorrectApi.js ================================================ 'use strict' // Description: A test script for the robot to load // // Commands: // hubot test - Responds with a test response // module.exports = {} ================================================ FILE: test/fixtures/TestScriptIncorrectApi.mjs ================================================ 'use strict' // Description: A test .mjs script for the robot to load // // Commands: // hubot test mjs - Responds with a test response from a .mjs script // export default {} ================================================ FILE: test/index_test.mjs ================================================ 'use strict' import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { Adapter, User, Brain, Robot, Response, Listener, TextListener, Message, TextMessage, EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage, loadBot } from '../index.mjs' import mockAdapter from './fixtures/MockAdapter.mjs' describe('hubot/index', () => { it('exports User class', () => { class MyUser extends User {} const user = new MyUser('id123', { foo: 'bar' }) assert.ok(user instanceof User) assert.equal(user.id, 'id123') assert.equal(user.foo, 'bar') }) it('exports Brain class', () => { class MyBrain extends Brain {} const robotMock = { on () { assert.ok(true) } } const brain = new MyBrain(robotMock) assert.ok(brain instanceof Brain) brain.set('foo', 'bar') assert.equal(brain.get('foo'), 'bar') }) it('exports Robot class', async () => { class MyRobot extends Robot {} const robot = new MyRobot(mockAdapter, false, 'TestHubot') await robot.loadAdapter() assert.ok(robot instanceof Robot) assert.equal(robot.name, 'TestHubot') robot.shutdown() }) it('exports Adapter class', () => { class MyAdapter extends Adapter {} const adapter = new MyAdapter('myrobot') assert.ok(adapter instanceof Adapter) assert.equal(adapter.robot, 'myrobot') }) it('exports Response class', () => { class MyResponse extends Response {} const robotMock = 'robotMock' const messageMock = { room: 'room', user: 'user' } const matchMock = 'matchMock' const response = new MyResponse(robotMock, messageMock, matchMock) assert.ok(response instanceof Response) assert.deepEqual(response.message, messageMock) assert.equal(response.match, matchMock) }) it('exports Listener class', () => { class MyListener extends Listener {} const robotMock = 'robotMock' const matcherMock = 'matchMock' const callback = () => {} const listener = new MyListener(robotMock, matcherMock, callback) assert.ok(listener instanceof Listener) assert.deepEqual(listener.robot, robotMock) assert.equal(listener.matcher, matcherMock) assert.equal(listener.options.id, null) assert.deepEqual(listener.callback, callback) }) it('exports TextListener class', () => { class MyTextListener extends TextListener {} const robotMock = 'robotMock' const regex = /regex/ const callback = () => {} const textListener = new MyTextListener(robotMock, regex, callback) assert.ok(textListener instanceof TextListener) assert.deepEqual(textListener.regex, regex) }) it('exports Message class', () => { class MyMessage extends Message {} const userMock = { room: 'room' } const message = new MyMessage(userMock) assert.ok(message instanceof Message) assert.deepEqual(message.user, userMock) }) it('exports TextMessage class', () => { class MyTextMessage extends TextMessage {} const userMock = { room: 'room' } const textMessage = new MyTextMessage(userMock, 'bla blah') assert.ok(textMessage instanceof TextMessage) assert.ok(textMessage instanceof Message) assert.equal(textMessage.text, 'bla blah') }) it('exports EnterMessage class', () => { class MyEnterMessage extends EnterMessage {} const userMock = { room: 'room' } const enterMessage = new MyEnterMessage(userMock) assert.ok(enterMessage instanceof EnterMessage) assert.ok(enterMessage instanceof Message) }) it('exports LeaveMessage class', () => { class MyLeaveMessage extends LeaveMessage {} const userMock = { room: 'room' } const leaveMessage = new MyLeaveMessage(userMock) assert.ok(leaveMessage instanceof LeaveMessage) assert.ok(leaveMessage instanceof Message) }) it('exports TopicMessage class', () => { class MyTopicMessage extends TopicMessage {} const userMock = { room: 'room' } const topicMessage = new MyTopicMessage(userMock) assert.ok(topicMessage instanceof TopicMessage) assert.ok(topicMessage instanceof Message) }) it('exports CatchAllMessage class', () => { class MyCatchAllMessage extends CatchAllMessage {} const messageMock = { user: { room: 'room' } } const catchAllMessage = new MyCatchAllMessage(messageMock) assert.ok(catchAllMessage instanceof CatchAllMessage) assert.ok(catchAllMessage instanceof Message) assert.deepEqual(catchAllMessage.message, messageMock) assert.deepEqual(catchAllMessage.user, messageMock.user) }) it('exports loadBot function', () => { assert.ok(loadBot && typeof loadBot === 'function') const robot = loadBot('adapter', false, 'botName', 'botAlias') assert.equal(robot.name, 'botName') assert.equal(robot.alias, 'botAlias') robot.shutdown() }) }) ================================================ FILE: test/ordered-scripts/01-PFirst.mjs ================================================ // Description: // First one // // Commands: // // Notes: // This is a test script. // export default async robot => { robot.loadedScripts = [] robot.loadedScripts.push('01-First') } ================================================ FILE: test/ordered-scripts/02-SetupBotConfig.mjs ================================================ // Description: // Setup bot configuration // // Commands: // // Notes: // This is a test script. // export default async robot => { robot.loadedScripts.push('02-Second') } ================================================ FILE: test/ordered-scripts/WebSetup.mjs ================================================ // Description: // Third one // // Commands: // // Notes: // This is a test script. // export default async robot => { robot.loadedScripts.push('03-Third') } ================================================ FILE: test/scripts/Xample.mjs ================================================ // Description: // Test script // // Commands: // hubot helo - Responds with HELO World!. // // Notes: // This is a test script. // export default (robot) => { robot.respond(/helo$/, async res => { await res.reply(`HELO World! I'm ${robot.name}.`) }) robot.respond(/helo (.*)/gi, async res => { await res.send(`Hello World! I'm ${robot.name}.`) }) robot.router.get('/helo', async (req, res) => { res.send(`HELO World! I'm ${robot.name}.`) }) }