A webhook-based Discord integration plugin. Uses Discord’s per-message username
and avatar_url overrides so a single webhook can post as different named players
with unique avatars. No bot account, no gateway connection — one-directional (game → Discord).
GET /avatars/:id (see avatar system)https://robohash.org/{encodedName}?set=set4&size=80x80 (unique per name, free)publicUrl in game config so the plugin can construct absolute avatar URLs.src/plugins/discord/
index.ts ← IPlugin entry, registers commands + hooks
webhook.ts ← low-level HTTP poster (queue, retry, 429 handling)
config.ts ← read/write webhook config from game DB config
commands.ts ← @discord/set, @discord/list, @discord/test
router.ts ← REST API for managing webhooks
ursamu.plugin.json
Stored in game config under the "discord" key:
{
"webhooks": {
"jobs": "https://discord.com/api/webhooks/…/…",
"ooc": "https://discord.com/api/webhooks/…/…",
"scenes": "https://discord.com/api/webhooks/…/…"
},
"publicUrl": "https://mygame.example.com"
}
publicUrl is used to construct avatar_url values for Discord.interface WebhookPayload {
content?: string;
username?: string;
avatar_url?: string;
embeds?: DiscordEmbed[];
}
Content-Type: application/json429 Too Many Requests — reads retry_after, waits, retries once@discord/set <topic>=<url> ← set or update a webhook URL
@discord/set <topic>= ← clear/disable a topic
@discord/list ← show configured topics (URLs truncated)
@discord/test <topic> ← post a test message to a topic
All endpoints require staff (admin/wizard).
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/discord/webhooks | List topics (URLs truncated to 40 chars) |
| POST | /api/v1/discord/webhooks | { topic, url } — set webhook |
| DELETE | /api/v1/discord/webhooks/:topic | Remove topic |
| POST | /api/v1/discord/webhooks/:topic/test | Send test message |
jobs topic — subscribes to jobHooks| Hook event | Discord output |
|---|---|
job:created |
Green embed: “New job #N — [title]” — submitter as username |
job:assigned |
Blue embed: “Job #N assigned to [staff]” |
job:commented |
Plain message as comment author (skips staffOnly comments) |
job:status-changed |
Yellow embed: status transition |
job:resolved |
Teal embed: “Job #N resolved by [staff]” |
job:closed |
Gray embed: “Job #N closed by [staff]” |
job:deleted |
Red embed: “Job #N deleted” |
ooc topic — in-game public channel relaySubscribe to channel talk events on channels flagged as public.
Post as the speaking player with their avatar.
Format: content: message (plain, no embed — keeps it readable)
scenes topic — scene pose relaySubscribe to scene pose events.
Post as the posing player with their avatar.
function avatarUrl(playerId: string, playerName: string, config: DiscordConfig): string {
// Check if player has a saved avatar
if (playerHasAvatar(playerId)) {
return `${config.publicUrl}/avatars/${playerId}`;
}
// Fallback to RoboHash
return `https://robohash.org/${encodeURIComponent(playerName)}?set=set4&size=80x80`;
}
Job created (embed):
{
"username": "Mira",
"avatar_url": "https://mygame.com/avatars/obj-42",
"embeds": [{
"color": 5763719,
"title": "New Job #17 — Fix the north gate",
"description": "The north gate latch is broken again.",
"footer": { "text": "Category: maintenance • Priority: normal" }
}]
}
OOC channel message:
{
"username": "Mira",
"avatar_url": "https://mygame.com/avatars/obj-42",
"content": "Anyone want to run a scene tonight?"
}
@discord/optout)src/commands/avatar.ts + /avatars/:id route) — must exist firstjobHooks from src/plugins/jobs/hooks.tsgetConfig / setConfig)publicUrl configured in game config