Creating Modules
Modules package commands, middleware, and setup logic into isolated units.
Drop a .js file into your modules directory and it loads automatically.
Module Basics
A module is a .js file that exports an async default function receiving the robot instance:
// modules/ping.js
import { Command, createTextResponse } from '@devchitchat/chatopsjs'
export default async function (robot) {
robot.commands.register(new Command({
id: 'ping',
description: 'Check if the bot is alive',
handler: async () => createTextResponse('Pong! ๐')
}))
}
Place this in your ./modules/ directory, then create your robot:
const robot = await Robot.create({ directory: './modules' })
// ping command is now registered automatically
Modules are loaded in alphabetical order by file path. Name files with a numeric prefix (e.g. 01-auth.js, 02-deploys.js) to control load order when it matters.
The Command Class
Every command is a Command instance with a canonical ID:
new Command({
id: 'tickets.create', // canonical ID โ must be unique
description: 'Open a new ticket', // shown in help
aliases: ['ticket new', 'tc'], // alternative invocations
args: {
title: { type: 'string', required: true },
priority: { type: 'string', required: false }
},
permissions: ['tickets:write'], // required grants
confirm: {
mode: 'yes-no',
message: 'Create a ticket? (yes/no)'
},
handler: async (ctx) => { /* ... */ }
})
Command ID Conventions
Use dot-notation for namespaced IDs โ it keeps related commands grouped in help output and avoids collisions between modules:
| Pattern | Example IDs |
|---|---|
resource.action | tickets.create, tickets.close |
service.action | deploy.trigger, deploy.rollback |
short | ping, help, status |
Arguments
Declare args in the command schema and access them via ctx.args:
robot.commands.register(new Command({
id: 'deploy',
args: {
service: { type: 'string', required: true },
version: { type: 'string', required: true },
environment: { type: 'string', required: false }
},
handler: async (ctx) => {
const { service, version, environment = 'staging' } = ctx.args
return createTextResponse(
`Deploying ${service} ${version} to ${environment}โฆ`
)
}
}))
// Invocation:
// deploy --service api --version v2.1 --environment production
Permissions
Commands declare required permissions as an array of strings. The framework checks them against envelope.actor.permissions before invoking the handler:
// Declare required permissions on the command
new Command({
id: 'deploy',
permissions: ['deploys:write'],
handler: async (ctx) => { /* ... */ }
})
// The actor must have 'deploys:write' or the command returns:
// { ok: false, error: { code: 'permission_denied' } }
Permissions are free-form strings โ define whatever scheme makes sense for your team:
// Common patterns:
'tickets:read'
'tickets:write'
'deploys:staging'
'deploys:production'
'incidents:ack'
'admin'
Confirmation Flow
Use confirm for commands with side effects that should require explicit approval:
new Command({
id: 'db.migrate',
confirm: {
mode: 'yes-no',
message: 'โ ๏ธ Run database migration on production? (yes/no)'
},
handler: async (ctx) => createTextResponse('Migration started.')
})
When a user runs db.migrate, the adapter holds the pending command and prompts for confirmation. The handler only executes after the user replies yes.
Response Types
Text Response
import { createTextResponse } from '@devchitchat/chatopsjs'
return createTextResponse('Operation complete. ๐')
Structured Message (IR)
The intermediate representation lets you express rich messages that each adapter renders natively:
import { createMessageResponse } from '@devchitchat/chatopsjs'
return createMessageResponse({
fallbackText: 'Deployment started',
blocks: [
{
type: 'section',
text: '๐ Deployment started'
},
{
type: 'facts',
items: [
{ label: 'Service', value: ctx.args.service },
{ label: 'Version', value: ctx.args.version },
{ label: 'Environment', value: ctx.args.env },
{ label: 'Triggered by', value: ctx.envelope.actor.id }
]
}
]
})
Native Provider Response
When you need full provider-specific control, use the native escape hatch:
import { createNativeResponse } from '@devchitchat/chatopsjs'
// Discord embed example
return createNativeResponse({
fallbackText: 'Alert acknowledged',
provider: 'discord',
payload: {
embeds: [{
title: 'โ
Alert Acknowledged',
color: 0x00c896,
description: `Alert **${ctx.args.id}** acked by ${ctx.envelope.actor.id}`,
timestamp: new Date().toISOString()
}]
}
})
Middleware in Modules
Modules can also register middleware that applies to all commands:
// modules/audit.js
export default async function (robot) {
robot.use(async (ctx, next) => {
const start = Date.now()
// Before: log the invocation
robot.log('command:start', {
command: ctx.command.id,
actor: ctx.envelope.actor.id,
correlationId: ctx.meta.correlationId
})
await next()
// After: log the result
robot.log('command:done', {
command: ctx.command.id,
duration: Date.now() - start
})
})
}
Using Storage
Commands get access to a ctx.storage interface for durable state:
// modules/counter.js
import { Command, createTextResponse } from '@devchitchat/chatopsjs'
export default async function (robot) {
robot.commands.register(new Command({
id: 'counter.increment',
description: 'Increment the shared counter',
handler: async (ctx) => {
const current = (await ctx.storage.get('counter')) ?? 0
const next = current + 1
await ctx.storage.set('counter', next)
return createTextResponse(`Counter: ${next}`)
}
}))
robot.commands.register(new Command({
id: 'counter.reset',
description: 'Reset the counter to zero',
permissions: ['admin'],
handler: async (ctx) => {
await ctx.storage.set('counter', 0)
return createTextResponse('Counter reset.')
}
}))
}
Pattern Listeners
For ambient messages that aren't explicit commands, use listeners:
// modules/emoji-reactions.js
export default async function (robot) {
robot.listeners.register(/๐/, async (envelope, match) => {
const adapter = robot.adapters.get(envelope.adapter)
if (adapter) {
await adapter.send(envelope, { text: '๐ Launch detected!' })
}
})
}
Prefer commands for anything that performs an action โ they're auditable, permissioned, and confirmable. Use listeners only for passive, ambient reactions that don't need access control.
Full Module Example
// modules/incidents.js
import { Command, createTextResponse, createMessageResponse } from '@devchitchat/chatopsjs'
export default async function (robot) {
robot.commands.register(new Command({
id: 'incident.declare',
description: 'Declare a new incident',
aliases: ['incident new', 'inc declare'],
args: {
severity: { type: 'string', required: true }, // sev1 | sev2 | sev3
title: { type: 'string', required: true },
channel: { type: 'string', required: false }
},
permissions: ['incidents:write'],
confirm: {
mode: 'yes-no',
message: '๐จ Declare a new incident? This will page on-call. (yes/no)'
},
handler: async (ctx) => {
const { severity, title } = ctx.args
const id = `INC-${Date.now()}`
await ctx.storage.set(id, { severity, title, declaredBy: ctx.envelope.actor.id })
return createMessageResponse({
fallbackText: `Incident ${id} declared`,
blocks: [
{ type: 'section', text: `๐จ **${id}** โ ${title}` },
{ type: 'facts', items: [
{ label: 'Severity', value: severity.toUpperCase() },
{ label: 'Declared by', value: ctx.envelope.actor.id },
{ label: 'Status', value: 'OPEN' }
]}
]
})
}
}))
robot.commands.register(new Command({
id: 'incident.ack',
description: 'Acknowledge an incident',
aliases: ['inc ack'],
args: { id: { type: 'string', required: true } },
permissions: ['incidents:write'],
handler: async (ctx) => {
const incident = await ctx.storage.get(ctx.args.id)
if (!incident) return createTextResponse(`No incident found: ${ctx.args.id}`)
return createTextResponse(
`โ
${ctx.args.id} acknowledged by ${ctx.envelope.actor.id}`
)
}
}))
}