Skip to content
60 changes: 60 additions & 0 deletions WEBSITE_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,63 @@ 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 outbound webhook subscriptions for lightweight website event integration.

### Configuration

Manage subscriptions through the public API:

- `GET /api/webhooks/games/event-types` lists supported event types.
- `POST /api/webhooks/games/subscriptions` replaces a user subscription for a 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.
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
}
13 changes: 13 additions & 0 deletions src/main/java/ti4/service/webhook/GameWebhookEventPayload.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ti4.service.webhook;

import java.util.Map;

public record GameWebhookEventPayload(
String gameName,
String eventType,
String phaseOfGame,
int round,
String activePlayerId,
String activeFaction,
String timestamp,
Map<String, Object> metadata) {}
20 changes: 20 additions & 0 deletions src/main/java/ti4/service/webhook/GameWebhookEventType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ti4.service.webhook;

public enum GameWebhookEventType {
ACTIVE_PLAYER_CHANGED("active_player_changed"),
PHASE_CHANGED("phase_changed"),
AGENDA_VOTING_STARTED("agenda_voting_started"),
AGENDA_RESOLVED("agenda_resolved"),
PLAYER_PASSED("player_passed"),
GAME_ENDED("game_ended");

private final String value;

GameWebhookEventType(String value) {
this.value = value;
}

public String value() {
return value;
}
}
83 changes: 83 additions & 0 deletions src/main/java/ti4/service/webhook/WebhookDispatchService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package ti4.service.webhook;

import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.springframework.stereotype.Service;
import ti4.executors.ExecutorServiceManager;
import ti4.game.Game;
import ti4.json.JsonMapperManager;
import ti4.logging.BotLogger;
import ti4.logging.LogOrigin;
import ti4.website.EgressClientManager;
import tools.jackson.databind.json.JsonMapper;

@Service
public class WebhookDispatchService {
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private static final int MAX_ATTEMPTS = 2;
private static final long RETRY_DELAY_MILLIS = 500L;

private final JsonMapper jsonMapper = JsonMapperManager.basic();

public void dispatch(Game game, String webhookUrl, GameWebhookEventPayload payload) {
ExecutorServiceManager.runAsync(
"webhook_dispatch_" + game.getName(), () -> sendWithRetry(game, webhookUrl, payload));
}
Comment on lines +24 to +27
Comment on lines +17 to +27

private void sendWithRetry(Game game, String webhookUrl, GameWebhookEventPayload payload) {
if (!isWebhookUrlValid(webhookUrl)) {
BotLogger.warning(new LogOrigin(game), "Skipping webhook dispatch due to invalid URL.");
return;
}

for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhookUrl))
.timeout(REQUEST_TIMEOUT)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(payload)))
.build();
Comment on lines +37 to +42

HttpResponse<String> response =
EgressClientManager.getHttpClient().send(request, HttpResponse.BodyHandlers.ofString());

if (response.statusCode() >= 200 && response.statusCode() < 300) {
return;
}

BotLogger.warning(
new LogOrigin(game),
"Webhook dispatch returned status "
+ response.statusCode()
+ " on attempt "
+ attempt
+ ". Response: "
+ response.body());
Comment on lines +51 to +58
} catch (Exception e) {
BotLogger.error(new LogOrigin(game), "Webhook dispatch failed on attempt " + attempt, e);
}

Comment on lines +47 to +62
if (attempt < MAX_ATTEMPTS) {
try {
Thread.sleep(RETRY_DELAY_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}

private boolean isWebhookUrlValid(String webhookUrl) {
try {
URI uri = URI.create(webhookUrl);
String scheme = uri.getScheme();
return uri.getHost() != null && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme));
} catch (RuntimeException e) {
return false;
}
}
}
Loading
Loading