Farmstead Mods
RU

Chat channels

Channel setup, channel permissions and relaying to Discord.

On this page

Examples#

Switching the shipped channels: local and global#

Understand which slash commands will appear at all: they are not in the mod's registry; ChannelCommands.java reads chat.channels at startup.

/l
a plain message without a prefix
/g Everyone: event at spawn in 5 minutes
/global

As shipped, local has command l, aliases local/lc, radius 100, default true and an empty prefix. /l without an argument prints channel.switched with the key local and remembers the channel in ChatHandler until the next switch. The next plain chat goes only to players in the same dimension within a radius of 100. /g <text> first switches to global (alias global; gc was removed from the shipped config because it clashed with the /gc diagnostic), then posts a ServerChatEvent — the message goes to everyone. The ! prefix (on global) is one-off: !hello goes to global, and the persistent channel does not change.

Staff chat moved to Chatter#

Understand why NeoEssentials does not register /staff, /admin, /mod and /s.

/s Check the griefer near spawn

Staff chat belongs to Chatter: /s <text> checks neoessentials.chat.staff via LuckPerms and sends the message only to online players with the same node. NeoEssentials has no staff channel and does not register /staff, /admin, /mod or /s; this rules out conflicting ServerChatEvent handlers. /ac is unrelated — it is an alias of /helpop.

Team channel without FTB Teams and with it#

The shipped config enables team with teamBased: true — understand what a player sees on a pack without FTB Teams, and what keeps vanilla /team from breaking.

/team
/t
/teamchat Hello, team

ChannelCommands attaches the literals team, t, teamchat from the config. If FTB Teams (ftbteams) is not loaded, sending gives commands.neoessentials.channel.no_team_provider and nothing is delivered. If the mod is there but the player is not in a team — channel.no_team. Otherwise online teammates see it; if the channel also has a permission, only teammates with that node do. radius does not combine with teamBased. Important: the team literal matches vanilla /team (scoreboard). The channel registration takes over that name — the vanilla create/add/join subcommands disappear while the config's command/aliases contain team.

A custom channel and Discord without a bot#

Add a donor channel with its own permission and a one-way webhook, without waiting for SDLink.

/donor Thanks for the subscription

In chat.channels, add an object with a safe key (donor, no colours or emoji): command donor, permission any string (for example neoessentials.chat.donor), discord.enabled true, webhookUrl the Discord webhook URL; channelId can stay empty. Channel commands are registered only at startup — after editing the JSON you need a restart; /neoe reload does not rebuild the tree. Routing (prefix, radius, permission, discord.enabled, webhookUrl) is read from disk by ChatHandler and WebhookAdapter on every message: the radius and webhook are picked up after reload, a new /donor is not. broadcastPlayerChat is called only if discord.enabled is true; an empty channelId hands the bridge its default channel, and the webhook still goes out if the URL is set.

Edge cases#

Channel commands are dynamic and not in the inventory#

At startup ChannelCommands.register walks chat.channels, skips the enabled and *-description keys and disabled channels (enabled: false), and attaches Commands.literal(command) — if there is no command field, the literal is the channel key — plus every element of aliases. The staff key is skipped deliberately: its /s command belongs to Chatter. These names are not in _inventory.json: the generator looks at registerCommand*, not the config. That is why commands on this page is empty: the inventory knows neither /local nor /g. /ac is helpop, not a channel switch. The master switch chat.channels.enabled: false (or modules.chatEnabled) skips registration entirely; plain chat then goes out as a global broadcast with no radius.

What to do: Take the list of slash commands from your own chat.channels (as shipped: /l//local//lc, /g//global, /team//t//teamchat). Changing command/aliases or enabling a channel needs a restart. For prefix/radius/permission/discord it is enough for the config to be re-read (messages read it live; the command tree does not).

Channel permission: switch, delivery and Discord are three different places#

The permission field is an arbitrary string. The switch (/local, /g, a custom channel's command or alias) checks it on the sender via PermissionValidator. Delivery in ChatHandler checks the same string on every recipient (for team — on top of team membership). Discord: if the sender lacks the string, broadcastPlayerChat is skipped even when players with the permission have already seen the message. The nodes neoessentials.chat.channel.local and neoessentials.chat.channel.global exist in PermissionRegistry, but the shipped config sets no permission field on local/global — these nodes close nothing by themselves. There is no separate neoessentials.chat.bypass.

What to do: To close local or a donor channel, set permission in the channel object; do not rely on channel.local. Staff chat is not a NeoEssentials channel: its permission neoessentials.chat.staff is checked by Chatter.

One broken channel no longer kills the others#

Each channel is in its own try/catch: a colour/emoji in the key or in command logs an error and skips only that channel. Previously a shared catch aborted the loop. The channel key is the identifier for routing, Discord and the fallback literal; a coloured name belongs only in displayName for {channel}/{neoessentials_channel}.

What to do: Use keys like local/staff/donor, not §cStaff. After fixing a broken command, restart so the literal appears.

Discord: enabled, empty channelId, webhookUrl#

ChatHandler relays only if discord.enabled is true. An empty channelId becomes null — the bridge adapter sends to its own default channel (with SDLink native playerMessages, NeoEssentials also skips the default route to avoid doubling; an override with a non-empty id is always sent). WebhookAdapter reads chat.channels.<key>.discord.webhookUrl itself, but the queue never reaches it until the handler has called broadcastPlayerChat — so enabled is still required.

What to do: For a separate Discord channel, set a non-empty channelId. Webhook only: enabled true + URL; a bridge is not required, and there is no Discord→MC direction.

Radius, dimension, teamBased#

A channel with radius sends to those in the same dimension with distanceTo <= radius. A permission on such a channel additionally filters recipients. teamBased: true ignores the radius. With no radius, permission or teamBased — a global broadcast. default: true is used when there is no persistent channel and no prefix matched; if no channel is default, getEffectiveChannel returns the string global even if that key does not exist.

What to do: As shipped, the default is local, not global. For "proximity only", turn off global.enabled or do not give players /g.

The gc alias and vanilla /team#

A comment in config.json: the former gc alias on global silently lost to the diagnostic /gc. It is no longer in the shipped config. The team literal of the team channel overrides the vanilla scoreboard command /team — ChannelCommands does not check whether the name is taken. The short aliases s, t, admin, mod can clash with other mods the same way; registration runs without isCommandRegistered.

What to do: Need vanilla /team? Change the team channel's command/aliases (for example, teamchat only) and restart. Do not put gc back into global's aliases.

/neoe reload does not re-register channel commands#

The tree is built once from NeoEssentials → ChannelCommands.register. Reload re-reads the JSON for the next message (prefix, radius, discord, delivery permission), but new literals do not appear and old ones are not removed. modules.chatEnabled / chat.channels.enabled are also startup-only. The staff command /s is registered by Chatter at server start.

What to do: After adding a channel, changing command/aliases or the system switch — a full restart. After changing radius/prefix/discord — a reload is enough, with no new slash commands.

Known issues#

The shipped team command breaks vanilla /team#

chat.channels.team.command = team. dispatcher.register(Commands.literal("team")) takes over the name of the vanilla scoreboard /team. This is not a parser bug — the conflict is built into the default config. The aliases t and teamchat do not clash with vanilla.

What to do: Rename the channel's command (keep teamchat) or set team.enabled: false if you do not use FTB Teams, then restart.

The ChatHandler comment about a shared try/catch is outdated#

The Javadoc of getChannelDisplayName still claims that one broken literal aborts registration of all channels after it. ChannelCommands already has a per-channel try/catch. This does not affect behaviour, only reading the sources.

What to do: Go by ChannelCommands.java, not by that comment.

Detailed guide#

The section below is the project's original documentation (docs/Wiki/ChatChannels.md). Links to other wiki pages are rewritten to this site; the guides themselves are not edited, so upstream syncs stay conflict-free.

Version: 1.0.5+build.54 · Config: config.json → chat.channels section


Overview#

Chat channels allow messages to be scoped to a specific audience — local proximity, a permission-gated group, or global. Each channel is a freeform entry under chat.channels with its own command, aliases, and behavior; there is no explicit "type" field — a channel's behavior is inferred purely from which optional keys it defines:

  • Has a radius key → proximity-based (only players within that many blocks, same dimension, receive the message)
  • Has teamBased: true → only reaches players on the sender's FTB Teams (or similar supported mod) team — see Team Channel below
  • Has a permission key (and no radius/teamBased) → permission-gated (only players holding that permission receive the message, and it can also gate a Discord relay)
  • None of the above → treated as a plain global broadcast channel

Config (config.json → chat.channels)#

chat.channels.enabled is the master on/off switch for the whole channel system (defaults to true; if false, no channel commands are registered and chat falls back to plain vanilla formatting).

Each channel entry (keyed by an arbitrary name, e.g. "local", "global", "staff") supports:

Key Description
enabled Enable/disable this channel
command Primary command to switch to (and optionally speak directly in) this channel
aliases List of additional command aliases
radius If present, makes this a proximity/local channel (block radius, same dimension)
teamBased If true, only reaches players on the sender's team (FTB Teams or another supported team mod). See Team Channel.
permission If present, only players with this permission receive messages sent to the channel; also gates the Discord relay for this channel. Combined with teamBased, it additionally restricts who among the sender's teammates receives the message.
prefix If a chat message starts with this literal prefix, it's routed to this channel for that one message (e.g. typing !hello in the default config routes to global)
default If true, this channel is used when a player hasn't explicitly switched channels and their message has no matching prefix
displayName Optional styled text shown wherever the {channel}/{neoessentials_channel} placeholder is used (see Chat Format Placeholders), e.g. "&d⚙ Staff". Falls back to the raw channel key if unset. See below for why this exists as a separate field.
discord.enabled Relay messages sent in this channel to Discord
discord.channelId Discord channel ID to relay to (blank = the Discord bridge mod's own default/fallback chat channel). See Discord Interoperability below before relying on this for a channel meant to be private.

There is no type or format key, and no discord.relayFromDiscord — the current implementation only relays Minecraft chat to Discord, not the reverse.

The channel key must stay a plain, simple string#

The key you give a channel ("local", "staff", etc.) is not just a label — it's the internal identifier used for prefix routing, the discord.channelId lookup, and — when command isn't set — the actual slash-command literal Brigadier registers for that channel (ChannelCommands#register). Don't put color codes, emoji, or other special characters in the key itself — a command literal Brigadier can't parse will fail to register (and no player could ever type it anyway even if it somehow registered). Each channel now registers in its own try/catch, so one broken channel only skips itself and logs an error — it no longer silently aborts registration for every channel listed after it in the file (a real bug prior to this).

If you want a styled/colored channel name, use displayName instead — it's shown via {channel} in your chat-format, completely independent of the safe internal key:

"staff": {
  "enabled": true,
  "command": "staff",
  "aliases": ["mod", "admin", "s"],
  "prefix": "@",
  "permission": "neoessentials.chat.staff",
  "displayName": "&d⚙ Staff",
  "discord": { "enabled": true, "channelId": "" }
}
"chat-format": {
  "default": "&8[{channel}&8]&r {neoessentials_prefix}{neoessentials_username}{neoessentials_suffix}: {MESSAGE}"
}

Typing in the staff channel now shows something like [⚙ Staff] PlayerName: message, with the gear icon and "Staff" rendered in the color you chose — while the channel's actual identifier (staff) stays a safe, working command name behind the scenes.

Switching channels with /<command> persists until changed again (ChatHandler.setPlayerChannel); running /<command> <message> switches and sends that one message immediately.

Use {channel} / {neoessentials_channel} in a chat-format template to show which channel a message was sent in — see Chat Format Placeholders.

Team Channel (FTB Teams or similar)#

Set "teamBased": true on a channel to scope it to the sender's team instead of a radius or permission node — only players on the same team as the sender receive the message:

"team": {
  "enabled": true,
  "command": "team",
  "aliases": ["t", "teamchat"],
  "displayName": "&a⛨ Team",
  "teamBased": true,
  "discord": { "enabled": false, "channelId": "" }
}

Team membership is resolved via TeamManager (com.zerog.neoessentials.teams), which currently has one provider, FtbTeamsAdapter for FTB Teams — detected via ModList.isLoaded("ftbteams") and talked to entirely through reflection (no compile-time dependency), so it's safe to have this channel configured even on packs without FTB Teams installed:

  • No supported team mod installed → sending in the channel replies with an error saying so (commands.neoessentials.channel.no_team_provider), nothing is broadcast.
  • Team mod installed but the sender isn't on a team → a different error (commands.neoessentials.channel.no_team), nothing is broadcast.
  • Team mod installed and the sender is on a team → message reaches only online teammates (and, if permission is also set, only the teammates who also hold that permission).

Adding support for another team mod (Towns and Nations, SimpleTeams, etc.) means implementing TeamProviderAdapter and registering it in TeamManager — the channel config and routing logic don't need to change.


Example Config#

This is (trimmed from) the actual shipped default in config.json:

"channels": {
  "enabled": true,
  "team": {
    "enabled": true,
    "command": "team",
    "aliases": ["t", "teamchat"],
    "displayName": "&a⛨ Team",
    "teamBased": true,
    "default": false,
    "discord": { "enabled": false, "channelId": "" }
  },
  "local": {
    "enabled": true,
    "radius": 100,
    "command": "l",
    "aliases": ["local", "lc"],
    "prefix": "",
    "default": true,
    "discord": { "enabled": false, "channelId": "" }
  },
  "global": {
    "enabled": true,
    "command": "g",
    "aliases": ["global"],
    "prefix": "!",
    "default": false,
    "discord": { "enabled": true, "channelId": "" }
  },
  "staff": {
    "enabled": true,
    "command": "staff",
    "aliases": ["mod", "admin", "s"],
    "prefix": "@",
    "permission": "neoessentials.chat.staff",
    "default": false,
    "discord": { "enabled": true, "channelId": "" }
  }
}

global's alias list used to also include gc, but that collides with the built-in memory/TPS diagnostics command (/gc) — the diagnostics command wins registration, silently making gc unusable for switching channels. It was dropped from the shipped default; use /g//global instead.

With this config, typing @hi sends hi to the staff channel for that message only, !hi sends it to global, and plain unprefixed chat goes to local (the default: true channel) unless the player has run /staff, /g, or /team to switch persistently. The team channel is shipped enabled by default too — see Team Channel below for what happens if no supported team mod is installed.


Discord Interoperability (Avoiding Duplicate / Leaked Messages)#

If you're seeing chat messages posted to Discord twice, or a channel you configured as private (e.g. staff) still showing up in your server's default/public Discord channel, this is almost always caused by running NeoEssentials' own per-channel Discord relay at the same time as the Discord bridge mod's (Simple Discord Link, Mc2Discord, DCIntegration) own independent, built-in "relay every chat message" feature — two completely separate systems both deciding to post the same message.

For Simple Discord Link specifically: its own config/simple-discord-link/simple-discord-link.toml has a [chat] playerMessages setting (true by default) that relays every Minecraft chat message to its own single configured chatChannelID, completely independent of and unaware of NeoEssentials' chat.channels.*.discord settings. With both active: - Any channel using discord.channelId: "" (the default/fallback case — this is global's and staff's shipped default) gets posted to Discord twice: once by NeoEssentials' own relay, once by SDLink's native relay. - A channel you intend to keep private, like staff, still gets relayed in full by SDLink's native relay regardless of whatever NeoEssentials' own settings say — SDLink's blanket relay has no concept of NeoEssentials' channels at all, so there is nothing NeoEssentials can configure to stop it from its side.

Fix: set playerMessages = false under [chat] in SDLink's own config file and restart, so NeoEssentials' own per-channel relay is the only one active. NeoEssentials logs a startup warning if it detects playerMessages = true while SDLink is loaded, specifically to catch this.

If you configure a real discord.channelId for a channel (e.g. a dedicated private staff channel), NeoEssentials will route that specific message there correctly — this was a genuine bug prior to the fix that shipped alongside this documentation, where a configured channelId was silently ignored and every relayed message always went to SDLink's own default channel regardless of what channelId said. That part is now fixed; the "SDLink's own native relay still leaks it elsewhere" part above is a separate, unavoidable interoperability issue that only the playerMessages = false fix resolves. For SDLink specifically, a channel-routed message is sent as a proper Discord embed (player name + skin avatar), built manually via JDA's own embed API to match SDLink's own chat-message look — SDLink's DiscordMessageBuilder has no channel-override capability, so this recreates that same visual style rather than settling for a plain text line.

Mc2Discord has the same class of channel-routing fix applied (a configured discord.channelId is now honored instead of silently ignored). Unlike SDLink's channel-routed messages, Mc2Discord's are sent as a plain PlayerName: message text line rather than a styled embed — no rich-embed send path currently exists for it. Mc2Discord's own core purpose is also "relay chat to Discord automatically," so the same double-post risk for a channel using a blank channelId almost certainly applies — check Mc2Discord's own config for a way to disable its automatic relay for channels NeoEssentials already handles explicitly, the same way playerMessages = false does for SDLink. NeoEssentials does not currently ship a startup diagnostic for Mc2Discord's config the way it does for SDLink's.

DCIntegration works differently: it relays chat entirely through its own vanilla-level mixins, with no supported hook for NeoEssentials to intercept or suppress. Because of that, onPlayerChat only ever acts for a DCIntegration setup when a channel has a specific discord.channelId configured — a channel left blank gets no explicit relay from NeoEssentials at all (trusting DCIntegration's own native relay to handle it, avoiding a duplicate). This means a channel with a real channelId set (e.g. a private staff channel) is correctly posted there by NeoEssentials, but DCIntegration's own native relay may also still post that same message to its own configured channel (general.botChannel / advanced.chatOutputChannelID) regardless, since it has no concept of NeoEssentials channels either — check DCIntegration's own config if you see a private channel still leaking elsewhere.

The rich embed SDLink builds for a channel-routed message is fully customizable via a top-level discordEmbedTemplate section in config.json (or its own templates/discord_embed.json split file — see Split Configs):

"discordEmbedTemplate": {
  "enabled": true,
  "authorName": "{player}",
  "authorIconUrl": "https://mc-heads.net/avatar/{uuid}",
  "description": "{message}",
  "color": "#5865F2",
  "footerText": "",
  "footerIconUrl": "",
  "showTimestamp": false
}
Key Description
enabled true = build the rich embed described below. false = fall back to a plain PlayerName: message line instead.
authorName Text shown as the embed author (top-left, bold)
authorIconUrl Small icon shown next to the author name — defaults to the sender's Minecraft skin avatar
description The embed's main body text
color Hex color for the embed's left-side bar, e.g. "#5865F2" (Discord blurple). Leave "" for Discord's default.
footerText / footerIconUrl Optional footer row. Leave both empty ("") to omit the footer entirely.
showTimestamp Adds the current time to the embed's footer area

Every text field (authorName, authorIconUrl, description, footerText) supports these placeholders:

Placeholder Value
{player} The sender's Minecraft username
{uuid} The sender's UUID (handy for building a custom avatar URL)
{message} The chat message content
{channel} The NeoEssentials chat channel name (local, global, staff, etc.)

This customization is currently SDLink-specific — Mc2Discord's and DCIntegration's channel-routed messages are always plain text, since neither has a rich-embed send path built yet (see above).

Mc2Discord's and DCIntegration's channel-routing fixes are based on reading their compiled public API directly, not live-tested against a running instance of either mod (unlike SDLink, which was verified live) — if you hit anything unexpected with either, please report it.


Permissions#

Node Description
neoessentials.chat.staff Used by the shipped default config to gate the staff channel (channel permissions are arbitrary strings you set per-channel in config — this is just the convention the default config uses)
neoessentials.chat.channel.local, neoessentials.chat.channel.global Registered permission-registry entries for the built-in local/global channels (informational/tab-completion; the local and global channels have no permission key by default, so these aren't actually enforced out of the box)

There is no generic neoessentials.chat.bypass permission — access to a channel is controlled solely by whether that channel's config defines a permission key.


Back to Wiki Home