Skip to content
63 changes: 63 additions & 0 deletions WEBSITE_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,66 @@ python scripts/convert_and_upload_to_s3.py
### Infrastructure

AWS infrastructure is managed via Terraform in the `aws-resources` repository. The setup uses OIDC for keyless authentication via the `GithubActionsRole` IAM role.

## Game Event Webhooks

The bot supports per-game outbound webhooks for lightweight event integration.

### Configuration

Use `/game webhook` in a game to configure delivery:

- `/game webhook url:<https://example.com/hook>` sets the webhook URL.
- `/game webhook enabled:true|false` toggles delivery on/off.
- `/game webhook allow_fow:true|false` controls FoW behavior (default is `false`).
- `/game webhook` with no options shows current config.
- `/game webhook clear:true` removes all webhook settings for the game.

### Event Types

The bot emits these event types:

- `active_player_changed`
- `phase_changed` (only strategy/action/status/agenda transitions)
- `agenda_voting_started`
- `agenda_resolved`
- `player_passed`
- `game_ended`

### Payload Schema

Each webhook POST sends JSON with this shape:

- `gameName` (string)
- `eventType` (string)
- `phaseOfGame` (string)
- `round` (number)
- `activePlayerId` (string or null)
- `activeFaction` (string or null)
- `timestamp` (ISO-8601 string)
- `metadata` (object, optional)

Example:

```json
{
"gameName": "pbd1234",
"eventType": "player_passed",
"phaseOfGame": "action",
"round": 4,
"activePlayerId": "123456789012345678",
"activeFaction": "hacan",
"timestamp": "2026-05-31T00:00:00Z",
"metadata": {
"passedPlayerId": "123456789012345678",
"passedFaction": "hacan",
"autoPass": false
}
}
```

### Delivery Semantics

- Best-effort delivery.
- At-most-once from game-flow perspective (events are triggered only on meaningful transitions).
- Dispatch is non-blocking; game flow continues if webhook delivery fails.
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class GameCommand implements ParentCommand {
new Tags(),
new GalacticEventsSetup(),
new GameOptions(),
new GameWebhook(),
new StartScenario())
.collect(Collectors.toMap(Subcommand::getName, subcommand -> subcommand));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package ti4.discord.interactions.commands.game;

import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import ti4.discord.interactions.commands.GameStateSubcommand;
import ti4.game.Game;
import ti4.message.MessageHelper;
import ti4.service.webhook.GameWebhookConfig;

class GameWebhook extends GameStateSubcommand {
private static final String OPTION_URL = "url";
private static final String OPTION_ENABLED = "enabled";
private static final String OPTION_ALLOW_FOW = "allow_fow";
private static final String OPTION_CLEAR = "clear";

public GameWebhook() {
super("webhook", "Set or inspect game webhook notifications", true, false);
addOptions(new OptionData(OptionType.STRING, OPTION_URL, "Webhook URL to send game event notifications to"));
addOptions(new OptionData(OptionType.BOOLEAN, OPTION_ENABLED, "Enable or disable webhook notifications"));
addOptions(new OptionData(
OptionType.BOOLEAN, OPTION_ALLOW_FOW, "Allow notifications for FoW games (default false for privacy)"));
addOptions(new OptionData(OptionType.BOOLEAN, OPTION_CLEAR, "Clear all webhook configuration"));
}

@Override
public void execute(SlashCommandInteractionEvent event) {
Game game = getGame();
boolean clear = event.getOption(OPTION_CLEAR, false, OptionMapping::getAsBoolean);
String webhookUrl = event.getOption(OPTION_URL, null, OptionMapping::getAsString);
OptionMapping enabledOption = event.getOption(OPTION_ENABLED);
OptionMapping allowFowOption = event.getOption(OPTION_ALLOW_FOW);

if (clear) {
GameWebhookConfig.clearWebhookConfig(game);
MessageHelper.sendMessageToEventChannel(event, "Webhook configuration cleared for this game.");
return;
}

boolean updated = false;
if (webhookUrl != null) {
String trimmedWebhookUrl = webhookUrl.trim();
if (!GameWebhookConfig.isWebhookUrlValid(trimmedWebhookUrl)) {
MessageHelper.sendMessageToEventChannel(event, "Invalid webhook URL. Only http(s) URLs are allowed.");
return;
}
GameWebhookConfig.setWebhookUrl(game, trimmedWebhookUrl);
updated = true;
}

if (enabledOption != null) {
GameWebhookConfig.setWebhookEnabled(game, enabledOption.getAsBoolean());
updated = true;
}
if (allowFowOption != null) {
GameWebhookConfig.setFowAllowed(game, allowFowOption.getAsBoolean());
updated = true;
}

String urlDisplay = GameWebhookConfig.getWebhookUrl(game).orElse("<not set>");
String response = "Webhook config for `" + game.getName() + "`"
+ (updated ? " updated.\n" : ":\n")
+ "- URL: " + urlDisplay + "\n"
+ "- Enabled: " + GameWebhookConfig.isWebhookEnabled(game) + "\n"
+ "- Allow FoW: " + GameWebhookConfig.isFowAllowed(game);
MessageHelper.sendMessageToEventChannel(event, response);
}
}
2 changes: 2 additions & 0 deletions src/main/java/ti4/game/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
import ti4.service.emoji.SourceEmojis;
import ti4.service.milty.MiltyDraftManager;
import ti4.service.option.FOWOptionService.FOWOption;
import ti4.service.webhook.GameEventNotifierFacade;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

Expand Down Expand Up @@ -1398,6 +1399,7 @@ public void updateActivePlayer(Player player) {
setActivePlayerID(player == null ? null : player.getUserID());
lastActivePlayerChange = newTime;
AutoPingMetadataManager.setupAutoPing(getName());
GameEventNotifierFacade.notifyActivePlayerChanged(this, prevPlayer, player);
}

public void setAutoPing(boolean status) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/ti4/helpers/AgendaHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import ti4.service.unit.AddUnitService;
import ti4.service.unit.CheckUnitContainmentService;
import ti4.service.unit.DestroyUnitService;
import ti4.service.webhook.GameEventNotifierFacade;

@UtilityClass
public final class AgendaHelper {
Expand Down Expand Up @@ -1490,6 +1491,7 @@ public static void resolveTime(Game game, String winner) {
if (winner == null) {
winner = getWinner(game);
}
GameEventNotifierFacade.notifyAgendaResolved(game, winner);
if (!game.isHiddenAgendaMode()) {
MessageHelper.sendMessageToChannel(game.getMainGameChannel(), getSummaryOfVotes(game, true) + "\n \n");
} else {
Expand Down Expand Up @@ -1865,6 +1867,7 @@ private static List<Button> getVoteButtonsVersion2(int minVote, int voteTotal) {

public static void startTheVoting(Game game) {
game.setPhaseOfGame("agendaVoting");
GameEventNotifierFacade.notifyAgendaVotingStarted(game);
Comment on lines 1869 to +1870
if (!game.getStoredValue("CommFormPreset").isEmpty()) {
autoResolve(
null,
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/ti4/service/game/EndGameService.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import ti4.service.statistics.game.WinningPathHelper;
import ti4.service.statistics.game.WinningPathPersistenceService;
import ti4.service.tigl.TiglReportService;
import ti4.service.webhook.GameEventNotifierFacade;
import ti4.spring.api.image.GameImageService;
import ti4.spring.context.SpringContext;

Expand Down Expand Up @@ -190,6 +191,7 @@ static void gameEndStuff(Game game, GenericInteractionCreateEvent event, boolean
game.setEndedDate(System.currentTimeMillis());
game.setAutoPing(false);
game.setAutoPingSpacer(0);
GameEventNotifierFacade.notifyGameEnded(game);

GameMessageManager.remove(List.of(game.getName()));

Expand Down
9 changes: 9 additions & 0 deletions src/main/java/ti4/service/game/StartPhaseService.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
import ti4.service.planet.PlanetService;
import ti4.service.strategycard.PickStrategyCardService;
import ti4.service.turn.StartTurnService;
import ti4.service.webhook.GameEventNotifierFacade;
import ti4.settings.users.UserSettingsManager;

@UtilityClass
Expand All @@ -83,7 +84,9 @@ public static void startPhase(GenericInteractionCreateEvent event, Game game, St
case "voting", "agendaVoting" -> AgendaHelper.startTheVoting(game);
case "shuffleDecks" -> game.shuffleDecks();
case "agenda" -> {
String previousPhase = game.getPhaseOfGame();
game.setPhaseOfGame("agenda");
GameEventNotifierFacade.notifyPhaseChanged(game, previousPhase, "agenda");
Button flipAgenda = Buttons.blue("flip_agenda", "Flip Agenda");
List<Button> buttons = List.of(flipAgenda);
MessageHelper.sendMessageToChannelWithButtons(
Expand Down Expand Up @@ -530,7 +533,9 @@ public static void startStrategyPhase(GenericInteractionCreateEvent event, Game
}
String message = firstSCPicker.getRepresentationUnfogged() + " is up to pick a strategy card.";
game.updateActivePlayer(firstSCPicker);
String previousPhase = game.getPhaseOfGame();
game.setPhaseOfGame("strategy");
GameEventNotifierFacade.notifyPhaseChanged(game, previousPhase, "strategy");
GMService.logActivity(game, "**Strategy** Phase for Round " + game.getRound() + " started.", true);
FowCommunicationThreadService.checkAllCommThreads(game);
SpinService.executeSpinsForTrigger(game, SpinService.AutoTrigger.STRATEGY);
Expand Down Expand Up @@ -834,7 +839,9 @@ public static void sendStatusReminders(Game game, Player player) {
}

public static void startStatusHomework(GenericInteractionCreateEvent event, Game game) {
String previousPhase = game.getPhaseOfGame();
game.setPhaseOfGame("statusHomework");
GameEventNotifierFacade.notifyPhaseChanged(game, previousPhase, "statusHomework");
game.setStoredValue("startTimeOfRound" + game.getRound() + "StatusHomework", System.currentTimeMillis() + "");
GMService.logActivity(game, "**StatusHomework** Phase for Round " + game.getRound() + " started.", true);
// first do cleanup if necessary
Expand Down Expand Up @@ -1102,7 +1109,9 @@ public static void postSurveyResults(Game game) {
public static void startActionPhase(GenericInteractionCreateEvent event, Game game, boolean incrementTgs) {
boolean isFowPrivateGame = game.isFowMode();
game.setStoredValue("willRevolution", "");
String previousPhase = game.getPhaseOfGame();
game.setPhaseOfGame("action");
GameEventNotifierFacade.notifyPhaseChanged(game, previousPhase, "action");
GMService.logActivity(game, "**Action** Phase for Round " + game.getRound() + " started.", true);
for (Player p2 : game.getRealPlayers()) {
ButtonHelperActionCards.checkForAssigningExtremeDuress(game, p2);
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/ti4/service/turn/PassService.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import ti4.helpers.StatusHelper;
import ti4.helpers.omega_phase.PriorityTrackHelper;
import ti4.message.MessageHelper;
import ti4.service.webhook.GameEventNotifierFacade;

@UtilityClass
public class PassService {
Expand All @@ -32,6 +33,7 @@ public static void passPlayerForRound(
}

player.setPassed(true);
GameEventNotifierFacade.notifyPlayerPassed(game, player, autoPass);
if (game.playerHasLeaderUnlockedOrAlliance(player, "olradincommander")) {
ButtonHelperCommanders.olradinCommanderStep1(player, game);
}
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/ti4/service/webhook/GameEventNotifier.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ti4.service.webhook;

import ti4.game.Game;
import ti4.game.Player;

public interface GameEventNotifier {
void notifyActivePlayerChanged(Game game, Player previousActivePlayer, Player activePlayer);

void notifyPhaseChanged(Game game, String previousPhaseOfGame, String currentPhaseOfGame);

void notifyAgendaVotingStarted(Game game);

void notifyAgendaResolved(Game game, String winner);

void notifyPlayerPassed(Game game, Player passedPlayer, boolean autoPass);

void notifyGameEnded(Game game);
}
61 changes: 61 additions & 0 deletions src/main/java/ti4/service/webhook/GameEventNotifierFacade.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ti4.service.webhook;

import lombok.experimental.UtilityClass;
import ti4.game.Game;
import ti4.game.Player;
import ti4.spring.context.SpringContext;

@UtilityClass
public class GameEventNotifierFacade {
private static final GameEventNotifier NO_OP_NOTIFIER = new GameEventNotifier() {
@Override
public void notifyActivePlayerChanged(Game game, Player previousActivePlayer, Player activePlayer) {}

@Override
public void notifyPhaseChanged(Game game, String previousPhaseOfGame, String currentPhaseOfGame) {}

@Override
public void notifyAgendaVotingStarted(Game game) {}

@Override
public void notifyAgendaResolved(Game game, String winner) {}

@Override
public void notifyPlayerPassed(Game game, Player passedPlayer, boolean autoPass) {}

@Override
public void notifyGameEnded(Game game) {}
};

public static void notifyActivePlayerChanged(Game game, Player previousActivePlayer, Player activePlayer) {
notifier().notifyActivePlayerChanged(game, previousActivePlayer, activePlayer);
}

public static void notifyPhaseChanged(Game game, String previousPhaseOfGame, String currentPhaseOfGame) {
notifier().notifyPhaseChanged(game, previousPhaseOfGame, currentPhaseOfGame);
}

public static void notifyAgendaVotingStarted(Game game) {
notifier().notifyAgendaVotingStarted(game);
}

public static void notifyAgendaResolved(Game game, String winner) {
notifier().notifyAgendaResolved(game, winner);
}

public static void notifyPlayerPassed(Game game, Player passedPlayer, boolean autoPass) {
notifier().notifyPlayerPassed(game, passedPlayer, autoPass);
}

public static void notifyGameEnded(Game game) {
notifier().notifyGameEnded(game);
}

private static GameEventNotifier notifier() {
try {
return SpringContext.getBean(GameEventNotifier.class);
} catch (RuntimeException e) {
return NO_OP_NOTIFIER;
}
}
Comment on lines +54 to +60
}
Loading
Loading