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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions