Files
factorio-expcluster/exp_discord/instance.ts
T
bbassieandClaude Opus 5.5 8e9f780cc2 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>
2026-10-14 13:34:14 +00: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);
}