Files
factorio-expcluster/exp_discord/instance.ts
T
0c593cd9fe Feature/discord plugin (#54)
* Add exp_discord plugin for Discord login and account linking

Players log in to the web interface with Discord through OAuth2 with the
identify scope, so no bot token is needed. After logging in they get a
code from the login page and type /link-discord <code> in game to link
that Factorio account. One Discord account can hold up to
exp_discord.max_accounts Factorio accounts (default 3), and linking a
name moves it off any other Discord account.

Sessions and link codes live in memory on the controller plugin, the
links are stored in database/exp_discord/accounts.json. The user page
shows the linked Discord account and lets the user, or anyone with
exp_discord.link.delete, unlink it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Link Discord from the user page and document the setup

The user page gets a Link Discord button. It asks the controller for a
one-time ticket bound to the logged in user, then goes through the
authorize route with it. The callback links the Discord account to that
user directly, without the in-game code, and goes back to the user page.
The link flow always shows the Discord consent screen.

The README covers registering the redirect URL and the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: bbassie <17990055+bbassie@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:05:15 +09:00

83 lines
2.9 KiB
TypeScript

import type { Instance, InstancePluginContext } from "@clusterio/host";
import type * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
/** Sent by the lua side when a player uses a link code. */
export type IpcLink = {
player_name: string,
code: string,
};
/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */
export type IpcPlayer = {
player_name: string,
};
/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */
export class InstancePlugin {
private constructor(
public instance: Instance,
public logger: lib.Logger,
public name: string,
) {}
static async fromContext(context: InstancePluginContext) {
const instance = context.instance;
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
instance.server.handle("exp_discord:link", plugin.handleLinkIPC.bind(plugin));
instance.server.handle("exp_discord:unlink", plugin.handleUnlinkIPC.bind(plugin));
instance.server.handle("exp_discord:get", plugin.handleGetIPC.bind(plugin));
return plugin;
}
async handleLinkIPC(event: IpcLink) {
try {
const discord = await this.instance.sendTo("controller", new messages.DiscordLinkRequest(
event.player_name, event.code,
));
await this.luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name });
} catch (err: any) {
await this.luaSend("receive_error", { player_name: event.player_name, message: err.message });
}
}
async handleUnlinkIPC(event: IpcPlayer) {
try {
const discord = await this.instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name));
await this.luaSend("receive_unlinked", { player_name: event.player_name, discord_name: discord?.name });
} catch (err: any) {
await this.luaSend("receive_error", { player_name: event.player_name, message: err.message });
}
}
async handleGetIPC(event: IpcPlayer) {
try {
const discord = await this.instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name));
await this.luaSend("receive_get", { player_name: event.player_name, discord_name: discord?.name });
} catch (err: any) {
await this.luaSend("receive_error", { player_name: event.player_name, message: err.message });
}
}
/** Hand an answer to lua, unless the instance stopped while it was being fetched. */
async luaSend(receiver: string, json: any) {
if (this.instance.status !== "running") {
return;
}
// Discord names can contain anything, so pick a long bracket level the json does not close
const text = JSON.stringify(json);
let level = "=";
while (text.includes(`]${level}]`)) {
level += "=";
}
await this.instance.sendRcon(
`/sc exp_discord.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true
);
}
}
export default async function (context: InstancePluginContext) {
await InstancePlugin.fromContext(context);
}