Skip to content

@robojs/tickets - Thread-based support tickets for Discord #446

Description

@Pkmmte

Summary

Build a first-party-quality plugin for Robo.js that turns any bot into a lightweight helpdesk: open/assign/escalate/close tickets in Discord threads, with optional web APIs (if @robojs/server is installed) so maintainers can build dashboards later. The plugin must feel Robo-native: file-based commands/events, Sage returns, Middleware for guards, Flashcore for durable storage, and State for ephemeral context.

Tech requirements

  • Language: TypeScript (ESM).
  • Package name: @robojs/tickets.
  • Follow Robo’s TS/ESM conventions.

Goals

  • Zero-config install: npx robo add @robojs/tickets. The plugin should “just work” after minimal in-guild setup.

  • Guild-level setup only: Channel/role choices are configured inside the guild (via slash commands/modals). No role/channel IDs in /config files.

  • Automatic integrations: If packages exist, use them—no toggles:

    • @robojs/moderation → escalate/attach moderation actions.
    • @robojs/i18n → localize messages.
    • @robojs/analytics → emit ticket events.
    • @robojs/cron → stale or auto-close flows (optional).
      Detection should be runtime (dynamic import) like how @robojs/server automatically switches engines (Node http → Fastify).
  • Web API when server present: If @robojs/server is installed, expose REST endpoints under /api/tickets/**, mirroring how @robojs/ai reveals /api/ai/chat.

Non-Goals

  • No SLAs (first-response/resolution timers).
  • No external DB requirement; Flashcore by default (adapters optional later).

User stories

  • Members can run /ticket open to create a private thread with staff visibility.
  • Staff can assign/claim, add watchers, escalate to moderation, close/archive—using buttons/selects.
  • Admins can run /ticket setup inside the server to choose intake channel and staff roles (no file config).

Discord requirements

  • Use private threads for each ticket in a configured intake channel. Bot needs CREATE_PRIVATE_THREADS, SEND_MESSAGES_IN_THREADS, MANAGE_THREADS. Threads should be archived on close.
  • Slash commands, buttons, selects, and modals must follow Discord interaction rules.

Architecture

Data model (Flashcore, per-guild namespace)

// Key: tickets:{guildId}:{ticketId}
interface Ticket {
  id: string
  guildId: string
  channelId: string        // parent channel for the thread
  threadId: string
  openerId: string
  assigneeId?: string
  watchers: string[]
  status: 'open'|'pending'|'escalated'|'closed'
  subject: string
  category?: string
  priority?: 'low'|'normal'|'high'
  createdAt: number
  updatedAt: number
  closedAt?: number
  history: Array<{ at: number; by: string; action: string; meta?: any }>
}

// Key: tickets:settings:{guildId}
interface TicketSettings {
  intakeChannelId: string
  staffRoleIds: string[]
  enablePrivateThreads?: true
  archiveMinutes?: number // default 1440
  categories?: string[]
  buttonsStyle?: 'compact'|'verbose'
}
  • Persistence: Flashcore KV with guildId namespace (tickets:${guildId}:*).
  • Ephemeral: State for cooldowns and in-flight UI context.

Commands (file-based)

  • /ticket open [category] [priority] → modal for subject/details → create private thread + header message w/ buttons.
  • /ticket setup (admin only) → interactive wizard to pick intake channel & staff roles (writes to Flashcore).
  • /ticket list [status] (staff) → recent tickets.
  • /ticket assign [user] (staff), /ticket close [reason] (staff), /ticket add-watcher [user], /ticket transfer [user|role].
    Robo auto-registers commands from /src/commands/**; return values are delivered by Sage.

Events & middleware

  • interactionCreate handlers for modals/buttons/selects (routes by customId prefixes like tickets:*).
  • Middleware tickets.guard.ts: role guard + per-user open cooldown; short-circuit with a friendly message (Sage).

Thread lifecycle

  • Create private thread in the intake channel; map archiveMinutes to Discord’s valid auto-archive durations.
  • On Close: post summary, archive thread, persist closedAt, append to history.
  • Optional (auto-detected): if @robojs/cron exists, schedule stale/auto-archive tasks.

Web API (enabled only if @robojs/server is installed)

Robo’s server plugin maps files in /src/api to routes (default /api prefix). We’ll follow the same pattern and expose a simple JSON API for dashboards, similar to how @robojs/ai exposes /api/ai/chat.

Routes (proposal):

  • GET /api/tickets — list tickets (query: status, limit, cursor).
  • GET /api/tickets/:id — fetch one.
  • POST /api/tickets — create (subject, details, category, priority, openerId).
  • PATCH /api/tickets/:id — assign/close/escalate/update fields.
  • POST /api/tickets/:id/watchers — add watcher.
  • DELETE /api/tickets/:id/watchers/:userId — remove watcher.

Implementation notes:

  • Put handlers under /src/api/tickets/** (file-route mapping). Return values or throw via RoboResponse as needed.
  • When the server plugin isn’t present, the API must not load.

TypeScript & ESM conventions

  • Use TS everywhere (types for Ticket/TicketSettings/helpers).
  • ESM: "type": "module"; align with Robo’s TS guidance. (Resources for ESM/TS interop if needed.)

File layout (generated by the plugin)

/src
  /commands
    /ticket
      open.ts            // shows modal
      setup.ts           // guild-level config wizard
      list.ts
      assign.ts
      close.ts
      add-watcher.ts
      transfer.ts
  /events
    interactionCreate/tickets.buttons.ts
    interactionCreate/tickets.modals.ts
  /middleware
    01-tickets.guard.ts  // cooldown + role guard
  /modules
    tickets/index.ts     // shared helpers/types
  /api                    // only if @robojs/server is installed
    /tickets
      index.ts           // GET/POST /api/tickets
      [id].ts            // GET/PATCH /api/tickets/:id
      [id]/watchers.ts   // POST /api/tickets/:id/watchers
      [id]/watchers/[userId].ts // DELETE
/locales
  en/tickets.json        // present only if @robojs/i18n is installed
  • Commands/events auto-register; returning values uses Sage.
  • API routes follow @robojs/server’s /src/api/api/** mapping.

Example snippets

/src/commands/ticket/open.ts

import type { CommandResult } from 'robo.js'
import { ModalBuilder, TextInputBuilder, ActionRowBuilder, TextInputStyle } from 'discord.js'

export default async (interaction): Promise<CommandResult> => {
  const modal = new ModalBuilder().setCustomId('tickets:open').setTitle('Open a Ticket')
  const subject = new TextInputBuilder().setCustomId('tickets:subject').setLabel('Subject').setStyle(TextInputStyle.Short).setRequired(true)
  const details = new TextInputBuilder().setCustomId('tickets:details').setLabel('Describe the issue').setStyle(TextInputStyle.Paragraph)
  modal.addComponents(
    new ActionRowBuilder<TextInputBuilder>().addComponents(subject),
    new ActionRowBuilder<TextInputBuilder>().addComponents(details)
  )
  await interaction.showModal(modal)
  return
}
  • Uses modals per Discord interactions; Sage handles deferral/replies.

/src/events/interactionCreate/tickets.modals.ts

import { Flashcore } from 'robo.js'
import { ChannelType, ThreadAutoArchiveDuration } from 'discord.js'

export default async (interaction) => {
  if (!interaction.isModalSubmit() || interaction.customId !== 'tickets:open') return
  const guild = interaction.guild
  const settings = await Flashcore.get(`tickets:settings:${guild.id}`) // set by /ticket setup
  const subject = interaction.fields.getTextInputValue('tickets:subject')
  const details = interaction.fields.getTextInputValue('tickets:details')

  const parent = await guild.channels.fetch(settings.intakeChannelId)
  const thread = await parent.threads.create({
    name: `🎟️ ${subject}`.slice(0, 90),
    autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
    type: ChannelType.PrivateThread
  })

  const msg = await thread.send({ content: `New ticket from <@${interaction.user.id}>` })
  const ticketId = msg.id
  await Flashcore.set(`tickets:${guild.id}:${ticketId}`, {
    id: ticketId, guildId: guild.id, channelId: parent.id, threadId: thread.id,
    openerId: interaction.user.id, watchers: [], status: 'open',
    subject, createdAt: Date.now(), updatedAt: Date.now(),
    history: [{ at: Date.now(), by: interaction.user.id, action: 'open', meta: { details } }]
  })

  return `Ticket created: ${thread}`
}
  • Durable storage via Flashcore; private thread creation aligns with Robo usage patterns.

/src/middleware/01-tickets.guard.ts

import { State } from 'robo.js'
const cooldown = new State('tickets:cooldown')

export default async ({ record, payload }) => {
  if (!record?.key?.startsWith('ticket/')) return
  const [interaction] = payload
  const key = `${interaction.user.id}:open`
  const now = Date.now()
  const until = cooldown.get<number>(key)
  if (until && until > now) {
    return { abort: true, result: `Please wait ${Math.ceil((until - now)/1000)}s before opening another ticket.` }
  }
  cooldown.set(key, now + 30_000)
}
  • Middleware short-circuits execution with friendly messages.

Web API route (only when @robojs/server is present):
/src/api/tickets/index.ts

import type { RoboRequest, RoboReply } from '@robojs/server'
import { Flashcore } from 'robo.js'

export default async (req: RoboRequest, reply: RoboReply) => {
  if (req.method === 'GET') {
    const { status = 'open' } = req.query as any
    // fetch & page results from Flashcore by guild context (to be defined)
    return { items: [], nextCursor: null }
  }
  if (req.method === 'POST') {
    const body = await req.json()
    // create a ticket record + (optionally) thread, if openerId & guild context provided
    return { id: 'new-ticket-id' }
  }
  return reply.code(405).send('Method not allowed')
}
  • Mirrors @robojs/server “file → route” behavior; throwing/returning shapes responses.

Integrations (auto-detected)

At runtime, attempt import() and proceed if successful; do not require config flags.

  • Moderation (@robojs/moderation): add “Escalate to moderation” action to create/link a case.
  • i18n (@robojs/i18n): if present, wrap strings with t('tickets:key'); ship a default en/tickets.json.
  • Analytics (@robojs/analytics): emit tickets_open, tickets_assign, tickets_close.
  • Cron (@robojs/cron): if installed, provide optional stale reminders/auto-close jobs.
    These follow Robo’s “plugins add capabilities instantly” philosophy and the pattern of server engine auto-selection.

Acceptance criteria

  • TypeScript only; compiles to ESM and runs in robo dev/start.
  • Guild-level setup works: /ticket setup selects intake channel & staff roles (persisted in Flashcore). No role/channel IDs in /config.
  • /ticket open creates a private thread, posts a header, and persists ticket.
  • Assign/Close/Watch actions via buttons/selects update the Flashcore record and the thread header; Close archives the thread.
  • Middleware guard enforces per-user cooldown and staff-role checks.
  • Auto integrations: If any of @robojs/moderation, @robojs/i18n, @robojs/analytics, @robojs/cron are installed, features activate automatically.
  • Web API exists only when @robojs/server is present and exposes at least:
    GET /api/tickets, GET /api/tickets/:id, POST /api/tickets, PATCH /api/tickets/:id.
  • Docs: README covers install (npx robo add @robojs/tickets), permissions, commands, Web API, and integration behavior.

References

  • Robo.js docs: Getting started, commands, Sage replies, middleware, Flashcore, internals (State), file structure.
  • @robojs/server: API routes via /src/api, request/reply types, throwable responses.
  • @robojs/ai: shows the pattern for exposing web APIs when the server plugin is present (/api/ai/chat). We’ll mirror this for /api/tickets/**.

How to claim (Hacktoberfest)

  • Comment to claim; keep PRs small & focused (commands → UI actions → persistence → middleware → optional integrations → web API).
  • Please follow Robo’s code style and TypeScript conventions; include type defs for public helpers.

Metadata

Metadata

Assignees

No one assigned

    Fields

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions