From d1e2db50b293682889a0a877d22873e2afa8ab77 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sat, 8 Aug 2026 17:59:08 +0000 Subject: [PATCH 01/94] zoo(feat): share intel between maps --- .../hooks/useLabelsMenu/useLabelsMenu.ts | 11 +- .../hooks/useStatusMenu/useStatusMenu.ts | 9 +- .../hooks/useTagMenu/useTagMenu.tsx | 12 +- .../useContextMenuSystemItems.tsx | 18 +- .../SolarSystemNodeDefault.tsx | 4 + .../SolarSystemNode/SolarSystemNodeTheme.tsx | 4 + .../map/components/SyncIntelAction.tsx | 36 ++ .../map/hooks/useSolarSystemNode.ts | 4 +- .../components/Comments/Comments.tsx | 6 +- .../MarkdownComment/MarkdownComment.tsx | 31 +- .../MarkdownEditor/MarkdownEditor.tsx | 6 +- .../SystemSettingsDialog.tsx | 155 ++++++--- .../helpers/structureTypes.ts | 1 + .../components/MapSettings/MapSettings.tsx | 9 +- .../MapSettings/components/AdminSettings.tsx | 60 ++-- .../MapSettings/components/IntelSettings.tsx | 122 +++++++ .../mapRootProvider/MapRootProvider.tsx | 1 + .../mapRootProvider/hooks/api/useMapInit.ts | 5 + assets/js/hooks/Mapper/types/comment.ts | 1 + assets/js/hooks/Mapper/types/mapHandlers.ts | 10 + assets/js/hooks/Mapper/types/mapUnionTypes.ts | 3 +- assets/js/hooks/Mapper/types/options.ts | 1 + config/runtime.exs | 6 + lib/wanderer_app/api/map.ex | 28 ++ lib/wanderer_app/api/map_system.ex | 6 + lib/wanderer_app/api/map_system_comment.ex | 19 +- lib/wanderer_app/api/map_system_structure.ex | 22 +- lib/wanderer_app/application.ex | 1 + lib/wanderer_app/env.ex | 6 +- lib/wanderer_app/map/intel_sync.ex | 277 ++++++++++++++++ .../map/server/map_server_systems_impl.ex | 27 ++ lib/wanderer_app/maps.ex | 27 ++ lib/wanderer_app/repositories/map_repo.ex | 4 + .../event_handlers/map_core_event_handler.ex | 198 ++++++++++- .../map_structures_event_handler.ex | 3 +- .../map_system_comments_event_handler.ex | 10 +- .../map_systems_event_handler.ex | 44 +++ .../live/map/map_event_handler.ex | 3 +- ...20260209100000_add_intel_source_map_id.exs | 30 ++ ...260209100001_add_inherited_from_map_id.exs | 44 +++ test/unit/map/intel_sync_test.exs | 311 ++++++++++++++++++ test/unit/map/intel_sync_validation_test.exs | 78 +++++ 42 files changed, 1527 insertions(+), 126 deletions(-) create mode 100644 assets/js/hooks/Mapper/components/map/components/SyncIntelAction.tsx create mode 100644 assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/IntelSettings.tsx create mode 100644 lib/wanderer_app/map/intel_sync.ex create mode 100644 priv/repo/migrations/20260209100000_add_intel_source_map_id.exs create mode 100644 priv/repo/migrations/20260209100001_add_inherited_from_map_id.exs create mode 100644 test/unit/map/intel_sync_test.exs create mode 100644 test/unit/map/intel_sync_validation_test.exs diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useLabelsMenu/useLabelsMenu.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useLabelsMenu/useLabelsMenu.ts index f87b62fb4..36df90d57 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useLabelsMenu/useLabelsMenu.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useLabelsMenu/useLabelsMenu.ts @@ -26,12 +26,13 @@ export const useLabelsMenu = ( systemId: string | undefined, onSystemLabels: (val: string) => void, onCustomLabelDialog: () => void, + disabled = false, ): (() => MenuItem[]) => { - const ref = useRef({ onSystemLabels, systemId, systems, onCustomLabelDialog }); - ref.current = { onSystemLabels, systemId, systems, onCustomLabelDialog }; + const ref = useRef({ onSystemLabels, systemId, systems, onCustomLabelDialog, disabled }); + ref.current = { onSystemLabels, systemId, systems, onCustomLabelDialog, disabled }; return useCallback(() => { - const { onSystemLabels, systemId, systems, onCustomLabelDialog } = ref.current; + const { onSystemLabels, systemId, systems, onCustomLabelDialog, disabled } = ref.current; const system = systemId ? getSystemById(systems, systemId) : undefined; const labels = new LabelsManager(system?.labels ?? ''); @@ -53,6 +54,7 @@ export const useLabelsMenu = ( { label: 'Labels', icon: PrimeIcons.BOOKMARK, + disabled, className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: hasLabels }), items: [ ...(labels.customLabel.length > 0 @@ -60,6 +62,7 @@ export const useLabelsMenu = ( { label: 'Clear custom label', icon: 'pi pi-trash', + disabled, command: () => { labels.updateCustomLabel(''); onSystemLabels(labels.toString()); @@ -70,12 +73,14 @@ export const useLabelsMenu = ( { label: 'Custom label', icon: 'pi pi-language', + disabled, command: onCustomLabelDialog, }, { separator: true }, ...statusList.map(x => ({ label: LABELS_INFO[x].name, icon: x === LABELS.clear ? PrimeIcons.TRASH : PrimeIcons.BOOKMARK, + disabled, command: () => { if (x === LABELS.clear) { labels.clearLabels(); diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts index 80ef71252..481c45664 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts @@ -11,12 +11,13 @@ export const useStatusMenu = ( systems: SolarSystemRawType[], systemId: string | undefined, onSystemStatus: (val: number) => void, + disabled = false, ): (() => MenuItem) => { - const ref = useRef({ onSystemStatus, systemId, systems }); - ref.current = { onSystemStatus, systemId, systems }; + const ref = useRef({ onSystemStatus, systemId, systems, disabled }); + ref.current = { onSystemStatus, systemId, systems, disabled }; return useCallback(() => { - const { onSystemStatus, systemId, systems } = ref.current; + const { onSystemStatus, systemId, systems, disabled } = ref.current; const system = systemId ? getSystemById(systems, systemId) : undefined; if (!system) { @@ -33,10 +34,12 @@ export const useStatusMenu = ( const menuItem: MenuItem = { label: 'Status', icon: PrimeIcons.BOLT, + disabled, className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedStatus }), items: statusList.map(x => ({ label: STATUS_NAMES[x], icon: x !== 0 ? `${PrimeIcons.BOLT} ${STATUS_COLOR_CLASSES[x]}` : PrimeIcons.BAN, + disabled, command: () => onSystemStatus(x), className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: x === system.status }), })), diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx index 3cb804d51..37961d993 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx @@ -36,12 +36,13 @@ export const useTagMenu = ( systems: SolarSystemRawType[], systemId: string | undefined, onSystemTag: (val?: string) => void, + disabled = false, ): (() => MenuItem) => { - const ref = useRef({ onSystemTag, systems, systemId }); - ref.current = { onSystemTag, systems, systemId }; + const ref = useRef({ onSystemTag, systems, systemId, disabled }); + ref.current = { onSystemTag, systems, systemId, disabled }; return useCallback(() => { - const { onSystemTag, systemId, systems } = ref.current; + const { onSystemTag, systemId, systems, disabled } = ref.current; const system = systemId ? getSystemById(systems, systemId) : undefined; const isSelectedTag = AVAILABLE_TAGS.includes(system?.tag ?? ''); @@ -49,11 +50,13 @@ export const useTagMenu = ( const menuItem: MenuItem = { label: 'Tag', icon: PrimeIcons.HASHTAG, + disabled, className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedTag }), items: [ { label: 'Digit', icon: PrimeIcons.TAGS, + disabled, className: '!h-[128px] suppress-menu-behaviour', template: () => { return ( @@ -66,6 +69,7 @@ export const useTagMenu = ( key={x} value={x} size="small" + disabled={disabled} className="p-[3px] justify-center" onClick={() => system?.tag !== x && onSystemTag(x)} > @@ -73,7 +77,7 @@ export const useTagMenu = ( ))} ) => { - const getTags = useTagMenu(systems, systemId, onSystemTag); - const getStatus = useStatusMenu(systems, systemId, onSystemStatus); - const getLabels = useLabelsMenu(systems, systemId, onSystemLabels, onCustomLabelDialog); + const { + data: { pings, isSubscriptionActive, options: mapOptions }, + } = useMapRootState(); + + // Intel-managed fields are owned by the source map, so their menus are read-only here. + const hasIntelSource = !!mapOptions?.intel_source_map_id; + + const getTags = useTagMenu(systems, systemId, onSystemTag, hasIntelSource); + const getStatus = useStatusMenu(systems, systemId, onSystemStatus, hasIntelSource); + const getLabels = useLabelsMenu(systems, systemId, onSystemLabels, onCustomLabelDialog, hasIntelSource); const getWaypointMenu = useWaypointMenu(onWaypointSet); const canLockSystem = useMapCheckPermissions([UserPermission.LOCK_SYSTEM]); const canManageSystem = useMapCheckPermissions([UserPermission.UPDATE_SYSTEM]); const canDeleteSystem = useMapCheckPermissions([UserPermission.DELETE_SYSTEM]); const getUserRoutes = useUserRoute({ userHubs, systemId, onUserHubToggle }); - const { - data: { pings, isSubscriptionActive }, - } = useMapRootState(); - const ping = useMemo(() => (pings.length === 1 ? pings[0] : undefined), [pings]); const isShowPingBtn = useMemo(() => { if (!isSubscriptionActive) { @@ -200,5 +203,6 @@ export const useContextMenuSystemItems = ({ onTogglePing, ping, isShowPingBtn, + hasIntelSource, ]); }; diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx index 52924c6e5..1137babab 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx @@ -18,6 +18,7 @@ import { Tag } from 'primereact/tag'; import { LocalCounter } from '@/hooks/Mapper/components/map/components/LocalCounter'; import { KillsCounter } from '@/hooks/Mapper/components/map/components/KillsCounter'; import { useLocalCounter } from '@/hooks/Mapper/components/hooks/useLocalCounter.ts'; +import { SyncIntelAction } from '@/hooks/Mapper/components/map/components/SyncIntelAction'; // let render = 0; export const SolarSystemNodeDefault = memo((props: NodeProps) => { @@ -156,6 +157,9 @@ export const SolarSystemNodeDefault = memo((props: NodeProps )} + {nodeVars.hasIntelSource && ( + + )} ) => { @@ -141,6 +142,9 @@ export const SolarSystemNodeTheme = memo((props: NodeProps) {nodeVars.hubs.includes(nodeVars.solarSystemId) && ( )} + {nodeVars.hasIntelSource && ( + + )} { + const { outCommand } = useMapState(); + + return ( + + + + + +
( diff --git a/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx b/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx index d64eb5c46..e6b0761a6 100644 --- a/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx +++ b/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx @@ -4,7 +4,7 @@ import { OnMapAddSystemCallback, OnMapSelectionChange } from '@/hooks/Mapper/com import { SystemCustomLabelDialog, SystemLinkSignatureDialog, - SystemSettingsDialog, + CustomSystemSettingsDialog, } from '@/hooks/Mapper/components/mapInterface/components'; import { Connections } from '@/hooks/Mapper/components/mapRootContent/components/Connections'; import { getSystemById } from '@/hooks/Mapper/helpers'; @@ -288,7 +288,7 @@ export const MapWrapper = () => { /> {openSettings != null && ( - setOpenSettings(null)} /> + setOpenSettings(null)} /> )} {openPing != null && ( ) => { updateDetailedKills, } = useCommandsSystems(); const { addConnections, removeConnections, updateConnection } = useCommandsConnections(); - const { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters } = - useCommandsCharacters(); + const { + charactersUpdated, + characterAdded, + characterRemoved, + characterUpdated, + presentCharacters, + readyCharactersUpdated, + allReadyCharactersCleared, + } = useCommandsCharacters(); const mapUpdated = useMapUpdated(); const mapRoutes = useRoutes(); const mapUserRoutes = useUserRoutes(); @@ -183,6 +192,15 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { case Commands.pingBlocked: pingBlocked(data as CommandPingBlocked); break; + + case Commands.readyCharactersUpdated: + readyCharactersUpdated(data as CommandReadyCharactersUpdated); + break; + + case Commands.allReadyCharactersCleared: + allReadyCharactersCleared(data as CommandAllReadyCharactersCleared); + break; + default: console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data); break; @@ -191,5 +209,33 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { emitMapEvent({ name: type, data }); }, }; - }, []); + }, [ + addComment, + addConnections, + addSystems, + allReadyCharactersCleared, + characterActivityData, + characterAdded, + characterRemoved, + characterUpdated, + charactersUpdated, + mapInit, + mapRoutes, + mapUpdated, + mapUserRoutes, + pingAdded, + pingCancelled, + presentCharacters, + readyCharactersUpdated, + removeComment, + removeConnections, + removeSystems, + trackingCharactersData, + updateConnection, + updateDetailedKills, + updateLinkSignatureToSystem, + updateSystemSignatures, + updateSystems, + userSettingsUpdated, + ]); }; diff --git a/assets/js/hooks/Mapper/mapRootProvider/types.ts b/assets/js/hooks/Mapper/mapRootProvider/types.ts index 923152417..5a911e29d 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/types.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/types.ts @@ -4,6 +4,7 @@ import { SignatureSettingsType } from '@/hooks/Mapper/constants/signatures.ts'; export enum AvailableThemes { default = 'default', pathfinder = 'pathfinder', + zoo = 'zoo', accessibleDark = 'accessible-dark', accessibleLarge = 'accessible-large', accessibleLargeColorblind = 'accessible-large-colorblind', diff --git a/assets/js/hooks/Mapper/types/commandsIn.ts b/assets/js/hooks/Mapper/types/commandsIn.ts index d3ce806e8..1d02b4ca0 100644 --- a/assets/js/hooks/Mapper/types/commandsIn.ts +++ b/assets/js/hooks/Mapper/types/commandsIn.ts @@ -4,4 +4,5 @@ export type CommandInCharactersTrackingInfo = { characters: TrackingCharacter[]; following: string | null; main: string | null; + ready_characters: string[]; }; diff --git a/assets/js/hooks/Mapper/types/mapHandlers.ts b/assets/js/hooks/Mapper/types/mapHandlers.ts index 4e624d9cf..01f027a94 100644 --- a/assets/js/hooks/Mapper/types/mapHandlers.ts +++ b/assets/js/hooks/Mapper/types/mapHandlers.ts @@ -44,6 +44,8 @@ export enum Commands { pingAdded = 'ping_added', pingCancelled = 'ping_cancelled', pingBlocked = 'ping_blocked', + readyCharactersUpdated = 'ready_characters_updated', + allReadyCharactersCleared = 'all_ready_characters_cleared', } export type Command = @@ -82,7 +84,9 @@ export type Command = | Commands.refreshTrackingData | Commands.pingAdded | Commands.pingCancelled - | Commands.pingBlocked; + | Commands.pingBlocked + | Commands.readyCharactersUpdated + | Commands.allReadyCharactersCleared; export type ClientEnv = { intelSharingEnabled: boolean; @@ -178,6 +182,17 @@ export type CommandPingCancelled = Pick; export type CommandPingBlocked = { reason: string; message: string; +}; +export type CommandUpdateReadyCharacters = { + ready_character_eve_ids: string[]; +}; +export type CommandReadyCharactersUpdated = { + user_id: string; + user_name: string; + ready_character_eve_ids: string[]; +}; +export type CommandAllReadyCharactersCleared = { + cleared_by_user_id: string; }; export interface UserSettings { @@ -232,6 +247,8 @@ export interface CommandData { [Commands.pingAdded]: CommandPingAdded; [Commands.pingCancelled]: CommandPingCancelled; [Commands.pingBlocked]: CommandPingBlocked; + [Commands.readyCharactersUpdated]: CommandReadyCharactersUpdated; + [Commands.allReadyCharactersCleared]: CommandAllReadyCharactersCleared; } export interface MapHandlers { @@ -262,6 +279,7 @@ export enum OutCommand { updateSignatures = 'update_signatures', updateSystemName = 'update_system_name', updateSystemTemporaryName = 'update_system_temporary_name', + updateSystemOwner = 'update_system_owner', updateSystemDescription = 'update_system_description', updateSystemLabels = 'update_system_labels', updateSystemLocked = 'update_system_locked', @@ -293,6 +311,9 @@ export enum OutCommand { updateCharacterTracking = 'updateCharacterTracking', updateFollowingCharacter = 'updateFollowingCharacter', updateMainCharacter = 'updateMainCharacter', + updateReadyCharacters = 'updateReadyCharacters', + getAllReadyCharacters = 'getAllReadyCharacters', + clearAllReadyCharacters = 'clearAllReadyCharacters', addPing = 'add_ping', cancelPing = 'cancel_ping', startTracking = 'startTracking', @@ -301,6 +322,9 @@ export enum OutCommand { setIntelSourceMap = 'set_intel_source_map', syncIntel = 'sync_intel', + updateSystemCustomFlags = 'update_system_custom_flags', + getAllianceNames = 'get_alliance_names', + getAllianceTicker = 'get_alliance_ticker', // Only UI commands openSettings = 'open_settings', showActivity = 'show_activity', diff --git a/assets/js/hooks/Mapper/utils/contextStore/types.ts b/assets/js/hooks/Mapper/utils/contextStore/types.ts index 19846e4ca..4c84ac717 100644 --- a/assets/js/hooks/Mapper/utils/contextStore/types.ts +++ b/assets/js/hooks/Mapper/utils/contextStore/types.ts @@ -1,4 +1,4 @@ -export type AnyProperty = T[keyof T]; +export type AnyProperty = T[keyof T] | undefined; export type PCDHandleBeforeUpdate = ( newVal: AnyProperty, diff --git a/assets/js/hooks/ping.ts b/assets/js/hooks/ping.ts index a1b265a21..d91cb8387 100644 --- a/assets/js/hooks/ping.ts +++ b/assets/js/hooks/ping.ts @@ -22,6 +22,10 @@ export default { }, ping(rtt) { this._nowMs = Date.now(); - this.pushEvent('ping', { rtt: rtt }); + try { + this.pushEvent('ping', { rtt: rtt }); + } catch { + // LiveView not connected yet, will retry on reconnect + } }, }; diff --git a/config/config.exs b/config/config.exs index 77c02bb5d..818b437c7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -145,6 +145,22 @@ config :git_ops, manage_readme_version: "README.md", version_tag_prefix: "v" +# Add this to the existing configuration +config :wanderer_app, :signature_cleanup, + # Default to 24 hours + max_age_hours: 24 + +# Replace the signature configuration with one that uses environment variables +config :wanderer_app, :signatures, + # Wormhole signatures expire after the configured hours (default 24, 0 means never expire) + wormhole_expiration_hours: + String.to_integer(System.get_env("SIGNATURE_WORMHOLE_EXPIRATION_HOURS") || "24"), + # All other signatures expire after the configured hours (default 72, 0 means never expire) + default_expiration_hours: + String.to_integer(System.get_env("SIGNATURE_DEFAULT_EXPIRATION_HOURS") || "72"), + # Don't expire signatures that have connections + preserve_connected: true + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs index 22f3fbf08..4f70ff155 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -83,6 +83,11 @@ intel_sharing_enabled = |> get_var_from_path_or_env("WANDERER_INTEL_SHARING_ENABLED", "false") |> String.to_existing_atom() +fleet_readiness_enabled = + config_dir + |> get_var_from_path_or_env("WANDERER_FLEET_READINESS_ENABLED", "false") + |> String.to_existing_atom() + map_subscription_characters_limit = config_dir |> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_CHARACTERS_LIMIT", 10_000) @@ -193,6 +198,7 @@ config :wanderer_app, intel_sharing_enabled: intel_sharing_enabled, restrict_maps_creation: restrict_maps_creation, restrict_acls_creation: restrict_acls_creation, + fleet_readiness_enabled: fleet_readiness_enabled, subscription_settings: %{ plans: [ %{ diff --git a/docs/ZOO-FORK.md b/docs/ZOO-FORK.md new file mode 100644 index 000000000..6b3c3b3a7 --- /dev/null +++ b/docs/ZOO-FORK.md @@ -0,0 +1,347 @@ +# Zoo Fork Documentation + +**Branch:** `guarzo/zoo` +**Last Updated:** 2025-11-30 + +This document describes the zoo fork's extensions to upstream Wanderer, including database schema changes, frontend themes, and features suitable for upstream contribution. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Database Schema Extensions](#database-schema-extensions) +3. [Theme System](#theme-system) +4. [Label System](#label-system) +5. [Signature Cleanup](#signature-cleanup) +6. [Fleet Readiness](#fleet-readiness) +7. [Upstream PR Recommendations](#upstream-pr-recommendations) + +--- + +## Overview + +The zoo fork adds EVE Online wormhole-specific features to Wanderer: + +| Feature | Purpose | Zoo-Only? | +|---------|---------|-----------| +| Zoo Theme | Custom visual styling for wormhole mapping | Yes | +| Label Semantics | EVE-specific label meanings (EOL, Crit, etc.) | Yes | +| System Ownership | Track corp/alliance ownership of systems | Yes | +| Fleet Readiness | Mark characters ready for fleet operations | Yes | +| On-Demand Signature Cleanup | Configurable automatic signature expiration | No (PR candidate) | +| Connection Loop Type | Self-connecting wormhole support | Yes | + +--- + +## Database Schema Extensions + +The zoo fork adds 5 columns across 2 tables: + +### map_system_v1 + +| Column | Type | Purpose | Migration | +|--------|------|---------|-----------| +| `custom_flags` | text | Arbitrary flags for zoo features | `20250122214138` | +| `owner_id` | text | Corporation or Alliance EVE ID | `20250204223853` | +| `owner_type` | text | Entity type: 'corp' or 'alliance' | `20250204223853` | +| `owner_ticker` | text | Display ticker [TICKER] | `20250307165740` | + +### map_user_settings_v1 + +| Column | Type | Purpose | Migration | +|--------|------|---------|-----------| +| `ready_characters` | text[] | Character EVE IDs marked as fleet-ready | `20250625024813` | + +### Migration Files + +``` +priv/repo/migrations/ +├── 20250122214138_add_zoo_flags.exs +├── 20250204223853_add_system_owners.exs +├── 20250307165740_add_owner_ticker.exs +└── 20250625024813_add_fleet_readiness_ready_characters.exs +``` + +### Rollback SQL (if needed) + +```sql +-- Remove zoo columns from map_system_v1 +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS custom_flags; +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_id; +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_type; +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_ticker; + +-- Remove zoo columns from map_user_settings_v1 +ALTER TABLE map_user_settings_v1 DROP COLUMN IF EXISTS ready_characters; +``` + +--- + +## Theme System + +Zoo adds a `zoo` theme alongside `default` and `pathfinder`. + +### Key Files + +| File | Purpose | +|------|---------| +| `assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss` | Zoo theme styles | +| `assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeZoo.tsx` | Zoo node component | +| `assets/js/hooks/Mapper/components/map/labelIconMap.tsx` | Label icons and mappings | + +### Theme Characteristics + +- **Node Style:** Custom node component with zoo-specific rendering +- **Connection Mode:** Strict (vs Loose for other themes) +- **Labels:** EVE wormhole-specific meanings (see Label System below) +- **Colors:** Custom color palette for wormhole states + +### CSS Class Namespace + +Zoo-specific CSS classes use the `eve-zoo-` prefix: + +```scss +.eve-zoo-effect-color-has-eol { fill: #FF69B4; } +.eve-zoo-effect-color-has-gas { fill: #FFFDD0; } +.eve-zoo-effect-color-is-critical { fill: #8B0000; } +.eve-zoo-effect-color-is-dead-end { fill: #34495E; } +``` + +--- + +## Label System + +The zoo fork repurposes upstream's generic labels (A/B/C/1/2/3) with EVE Online wormhole-specific meanings. + +### Label Mappings + +| Key | Upstream | Zoo Meaning | Icon | Use Case | +|-----|----------|-------------|------|----------| +| `la`/`de` | Label A | Dead End | Block | System with no exit wormholes | +| `lb`/`gas` | Label B | Gas Site | Industry | System has harvestable gas sites | +| `lc`/`eol` | Label C | End of Life | Hourglass | Wormhole about to collapse (<4h) | +| `l1`/`crit` | Label 1 | Critical Mass | Fire | Wormhole at mass verge | +| `l2`/`structure` | Label 2 | Structure | Warning | System has attackable structure | +| `l3`/`steve` | Label 3 | Steve/Danger | Skull | High danger (historic name) | + +### Storage + +Labels are stored in the database using the original upstream keys (`la`, `lb`, etc.) but displayed with zoo-specific names and icons when the zoo theme is active. + +### Files + +- **Definition:** `assets/js/hooks/Mapper/components/map/labelIconMap.tsx` +- **Styles:** `assets/js/hooks/Mapper/components/map/constants.ts` (MARKER_BOOKMARK_BG_STYLES) +- **CSS:** `assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss` + +--- + +## Signature Cleanup + +Zoo implements on-demand signature cleanup in addition to upstream's daily batch cleanup. + +### Comparison + +| Aspect | Upstream GarbageCollector | Zoo On-Demand Cleanup | +|--------|---------------------------|----------------------| +| **Location** | `map_garbage_collector.ex` | `map_signatures_event_handler.ex` | +| **Trigger** | Daily via Quantum scheduler | When user views/updates signatures | +| **Scope** | All signatures globally | Per-system | +| **Wormhole Expiration** | 14 days (hardcoded) | 24 hours (configurable) | +| **Other Signatures** | 14 days (hardcoded) | 72 hours (configurable) | +| **Preserve Connected** | No | Yes (configurable) | + +### Configuration + +```elixir +# config/config.exs +config :wanderer_app, :signatures, + wormhole_expiration_hours: 24, # env: SIGNATURE_WORMHOLE_EXPIRATION_HOURS + default_expiration_hours: 72, # env: SIGNATURE_DEFAULT_EXPIRATION_HOURS + preserve_connected: true + +config :wanderer_app, :signature_cleanup, + max_age_hours: 24 +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SIGNATURE_WORMHOLE_EXPIRATION_HOURS` | 24 | Hours until wormhole signatures expire | +| `SIGNATURE_DEFAULT_EXPIRATION_HOURS` | 72 | Hours until non-wormhole signatures expire | + +Set to `0` to disable automatic cleanup for that signature type. + +### How They Interact + +1. Zoo cleanup runs first (on user interaction) with aggressive thresholds +2. Upstream cleanup runs daily as a safety net +3. No conflict: zoo deletes before upstream sees the signatures +4. Upstream catches signatures in never-accessed systems + +--- + +## Fleet Readiness + +Allows users to mark characters as "ready for fleet" operations. + +### Features + +- Mark/unmark characters as fleet-ready +- View list of ready characters with locations and ships +- Per-map user settings storage + +### Implementation + +| Component | Location | +|-----------|----------| +| UI Components | `assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/` | +| Event Handler | `lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex` | +| Ash Resource | `lib/wanderer_app/api/map_user_settings.ex` (update_ready_characters action) | +| Repository | `lib/wanderer_app/repositories/map_user_settings_repo.ex` | + +--- + +## Upstream PR Recommendations + +### Tier 1: Strongly Recommend + +These features are well-implemented, low-risk, and provide universal value: + +#### 1. On-Demand Signature Cleanup + +**Why:** All Wanderer users deal with signature clutter. This is configurable, disabled by default, and complements the existing GarbageCollector. + +**Scope:** +- `map_signatures_event_handler.ex` (cleanup_expired_signatures/1 function) +- `config/config.exs` (signature cleanup configuration) +- ~80 lines of code, no breaking changes + +**Suggested PR Title:** "feat: Add configurable on-demand signature cleanup" + +#### 2. Corporation/Alliance Ticker Fetching + +**Why:** Generic utility that any feature showing corp/alliance info could use. Minimal footprint, uses existing ESI infrastructure. + +**Scope:** +- `map_systems_event_handler.ex` (get_corporation_ticker, get_alliance_ticker handlers) +- ~30 lines of code, no breaking changes + +**Suggested PR Title:** "feat: Add corporation/alliance ticker lookup via UI events" + +#### 3. Idempotent Migration Pattern + +**Why:** Best practice documentation. Migrations should be safe to re-run. + +**Scope:** Documentation PR only + +**Pattern:** +```elixir +def up do + execute("ALTER TABLE table_name ADD COLUMN IF NOT EXISTS col text") +end + +def down do + execute("ALTER TABLE table_name DROP COLUMN IF EXISTS col") +end +``` + +### Tier 2: Consider with Modifications + +#### Configurable Label System + +The pattern of theme-configurable labels is valuable, but requires refactoring to make labels theme-aware. Better suited for discussion issue first. + +#### Fleet Readiness / Character Tagging + +Could be generalized to a "character tags" system. Requires significant refactoring. + +### Tier 3: Keep Zoo-Only + +| Feature | Reason | +|---------|--------| +| Zoo Theme | Highly specific to EVE wormhole gameplay | +| System Ownership | Specific to tracking wormhole space occupation | +| Custom Flags | Generic "store anything" field lacks structure | +| Connection Loop Type | Niche EVE mechanic | + +--- + +## Key Files Reference + +### Frontend (Zoo-Specific) + +``` +assets/js/hooks/Mapper/ +├── components/map/ +│ ├── styles/zoo-theme.scss +│ ├── labelIconMap.tsx +│ ├── constants.ts (modified) +│ └── components/ +│ ├── SolarSystemNode/SolarSystemNodeZoo.tsx +│ └── ZooIcons/ +├── components/mapRootContent/components/FleetReadiness/ +└── types/connection.ts (ConnectionType.loop added) +``` + +### Backend (Zoo-Specific) + +``` +lib/wanderer_app/ +├── api/ +│ ├── map_system.ex (owner_*, custom_flags attributes) +│ └── map_user_settings.ex (ready_characters attribute) +└── repositories/ + ├── map_system_repo.ex (update_owner function) + └── map_user_settings_repo.ex (ready_characters functions) + +lib/wanderer_app_web/live/map/event_handlers/ +├── map_systems_event_handler.ex (ticker fetching) +├── map_signatures_event_handler.ex (cleanup_expired_signatures) +└── map_characters_event_handler.ex (fleet readiness) +``` + +### Migrations + +``` +priv/repo/migrations/ +├── 20250122214138_add_zoo_flags.exs +├── 20250204223853_add_system_owners.exs +├── 20250307165740_add_owner_ticker.exs +└── 20250625024813_add_fleet_readiness_ready_characters.exs +``` + +### Configuration + +``` +config/config.exs (signature cleanup configuration, lines 149-159) +``` + +--- + +## Maintenance Notes + +### Merge Conflict Hotspots + +When merging upstream, watch for conflicts in: + +1. `constants.ts` - Label and bookmark style changes +2. `map_system.ex` - Attribute additions +3. `config/config.exs` - Configuration additions + +### Testing + +```bash +# Test migration idempotency +MIX_ENV=test mix ecto.reset +MIX_ENV=test mix ecto.migrate +MIX_ENV=test mix ecto.migrate # Should not fail + +# Verify configuration loads +MIX_ENV=dev iex -S mix -e "IO.inspect(Application.get_env(:wanderer_app, :signatures))" + +# Build frontend +cd assets && yarn build +``` diff --git a/lib/wanderer_app/map/README.md b/lib/wanderer_app/map/README.md new file mode 100644 index 000000000..64036e062 --- /dev/null +++ b/lib/wanderer_app/map/README.md @@ -0,0 +1,74 @@ +# Map Cleanup Systems + +## Overview + +The application has two signature cleanup systems that operate in parallel: + +### 1. Upstream GarbageCollector (Daily Batch) + +- **Location:** `lib/wanderer_app/map/map_garbage_collector.ex` +- **Schedule:** Daily via Quantum (`@daily`) +- **Thresholds:** Chain passages: 7 days, Signatures: 14 days +- **Scope:** All signatures globally +- **Configuration:** Hardcoded (not configurable) + +### 2. Zoo On-Demand Cleanup (User-Triggered) + +- **Location:** `lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex` +- **Trigger:** When user views or updates signatures +- **Thresholds:** Wormholes: 24h, Other: 72h (configurable) +- **Scope:** Per-system +- **Configuration:** Environment variables + +## Configuration (Zoo) + +```elixir +# config/config.exs +config :wanderer_app, :signatures, + wormhole_expiration_hours: 24, # SIGNATURE_WORMHOLE_EXPIRATION_HOURS + default_expiration_hours: 72, # SIGNATURE_DEFAULT_EXPIRATION_HOURS + preserve_connected: true + +config :wanderer_app, :signature_cleanup, + max_age_hours: 24 +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SIGNATURE_WORMHOLE_EXPIRATION_HOURS` | 24 | Hours before wormhole signatures expire (0 = never) | +| `SIGNATURE_DEFAULT_EXPIRATION_HOURS` | 72 | Hours before non-wormhole signatures expire (0 = never) | + +## How They Interact + +- Zoo cleanup runs first (on user interaction) with aggressive thresholds +- Upstream cleanup runs daily and catches anything zoo missed +- No conflict risk: zoo deletes before upstream ever sees the signatures +- Upstream acts as a safety net for never-accessed systems + +## Cleanup Logic (Zoo) + +The zoo cleanup (`cleanup_expired_signatures/1`) works as follows: + +1. Loads all signatures for the system +2. Calculates cutoff times based on signature type: + - Wormhole signatures: `wormhole_expiration_hours` (default 24h) + - Other signatures: `default_expiration_hours` (default 72h) +3. Optionally preserves connected signatures (`preserve_connected: true`) +4. Deletes expired signatures and broadcasts updates +5. Additionally cleans very old signatures (`max_age_hours`) as a safety net + +## Disabling Options + +To disable zoo cleanup: Set both expiration hours to 0 +```bash +SIGNATURE_WORMHOLE_EXPIRATION_HOURS=0 +SIGNATURE_DEFAULT_EXPIRATION_HOURS=0 +``` + +To disable upstream cleanup: Comment out scheduler jobs in `config/runtime.exs`: +```elixir +# {"@daily", {WandererApp.Map.GarbageCollector, :cleanup_chain_passages, []}}, +# {"@daily", {WandererApp.Map.GarbageCollector, :cleanup_system_signatures, []}} +``` diff --git a/lib/wanderer_app/map/map_garbage_collector.ex b/lib/wanderer_app/map/map_garbage_collector.ex index f855d89d2..94a7947cc 100644 --- a/lib/wanderer_app/map/map_garbage_collector.ex +++ b/lib/wanderer_app/map/map_garbage_collector.ex @@ -13,11 +13,34 @@ defmodule WandererApp.Map.GarbageCollector do def cleanup_chain_passages() do Logger.info("Start cleanup old map chain passages...") - WandererApp.Api.MapChainPassages - |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@one_week_seconds)]) - |> Ash.bulk_destroy!(:destroy, %{}, batch_size: 100) + # Use return_errors? to handle stale records gracefully + result = + WandererApp.Api.MapChainPassages + |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@one_week_seconds)]) + |> Ash.bulk_destroy(:destroy, %{}, batch_size: 100, return_errors?: true) - @logger.info(fn -> "All map chain passages processed" end) + case result do + {:ok, %{errors: []}} -> + @logger.info(fn -> "All map chain passages processed successfully" end) + + {:ok, %{errors: errors}} when is_list(errors) -> + non_stale_errors = + Enum.reject(errors, fn + {_, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} -> true + _ -> false + end) + + if non_stale_errors != [] do + Logger.warning("Some chain passages failed to delete: #{inspect(non_stale_errors)}") + end + + @logger.info(fn -> + "Map chain passages processed with #{length(errors)} race conditions" + end) + + {:error, error} -> + Logger.error("Failed to cleanup chain passages: #{inspect(error)}") + end :ok end @@ -25,11 +48,35 @@ defmodule WandererApp.Map.GarbageCollector do def cleanup_system_signatures() do Logger.info("Start cleanup old map system signatures...") - WandererApp.Api.MapSystemSignature - |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@two_weeks_seconds)]) - |> Ash.bulk_destroy!(:destroy, %{}, batch_size: 100) + # Use return_errors? to handle stale records gracefully (race conditions with on-demand cleanup) + result = + WandererApp.Api.MapSystemSignature + |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@two_weeks_seconds)]) + |> Ash.bulk_destroy(:destroy, %{}, batch_size: 100, return_errors?: true) + + case result do + {:ok, %{errors: []}} -> + @logger.info(fn -> "All map system signatures processed successfully" end) + + {:ok, %{errors: errors}} when is_list(errors) -> + # Filter out stale record errors (expected race condition) + non_stale_errors = + Enum.reject(errors, fn + {_, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} -> true + _ -> false + end) + + if non_stale_errors != [] do + Logger.warning("Some signatures failed to delete: #{inspect(non_stale_errors)}") + end + + @logger.info(fn -> + "Map system signatures processed with #{length(errors)} race conditions" + end) - @logger.info(fn -> "All map system signatures processed" end) + {:error, error} -> + Logger.error("Failed to cleanup signatures: #{inspect(error)}") + end :ok end diff --git a/lib/wanderer_app/map/map_server.ex b/lib/wanderer_app/map/map_server.ex index aec880dd7..22ed3bdd3 100644 --- a/lib/wanderer_app/map/map_server.ex +++ b/lib/wanderer_app/map/map_server.ex @@ -66,6 +66,10 @@ defmodule WandererApp.Map.Server do defdelegate remove_hub(map_id, hub_info), to: Impl + defdelegate update_system_owner(map_id, update), to: Impl + + defdelegate update_system_custom_flags(map_id, update), to: Impl + defdelegate add_ping(map_id, ping_info), to: Impl defdelegate cancel_ping(map_id, ping_info), to: Impl diff --git a/lib/wanderer_app/map/server/map_server_characters_impl.ex b/lib/wanderer_app/map/server/map_server_characters_impl.ex index 4c77d9b7c..753f3b43c 100644 --- a/lib/wanderer_app/map/server/map_server_characters_impl.ex +++ b/lib/wanderer_app/map/server/map_server_characters_impl.ex @@ -312,8 +312,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do defp remove_and_untrack_characters(map_id, character_ids) do # Option 4: Enhanced logging for character removal - Logger.info(fn -> - "[CharacterCleanup] Map #{map_id} - starting removal of #{length(character_ids)} characters: #{inspect(character_ids)}" + Logger.warning(fn -> + "[CharacterCleanup] Map #{map_id} - permission-driven removal of #{length(character_ids)} characters: " <> + "#{inspect(character_ids)}, reason=acl_permission_revoked_3x" end) # Emit telemetry for monitoring @@ -908,12 +909,18 @@ defmodule WandererApp.Map.Server.CharactersImpl do end defp update_location( - _state, - _character_id, - _location, + %{map_id: map_id} = _state, + character_id, + location, %{solar_system_id: nil} - ), - do: :ok + ) do + Logger.warning( + "[CharacterTracking] Skipped system add for character #{character_id} on map #{map_id}: " <> + "new_system=#{inspect(location.solar_system_id)}, reason=nil_old_solar_system_id" + ) + + :ok + end defp update_location( %{map: map, map_id: map_id, map_opts: map_opts} = diff --git a/lib/wanderer_app/map/server/map_server_impl.ex b/lib/wanderer_app/map/server/map_server_impl.ex index a173d87cf..21a207d93 100644 --- a/lib/wanderer_app/map/server/map_server_impl.ex +++ b/lib/wanderer_app/map/server/map_server_impl.ex @@ -259,6 +259,10 @@ defmodule WandererApp.Map.Server.Impl do defdelegate update_connection_custom_info(map_id, connection_update), to: ConnectionsImpl defdelegate update_signatures(map_id, signatures_update), to: SignaturesImpl + defdelegate update_system_owner(map_id, update), to: SystemsImpl + + defdelegate update_system_custom_flags(map_id, update), to: SystemsImpl + def import_settings(map_id, settings, user_id) do WandererApp.Cache.put( "map_#{map_id}:importing", @@ -462,12 +466,13 @@ defmodule WandererApp.Map.Server.Impl do not WandererApp.Cache.lookup!("map_#{map_id}:importing", false) and WandererApp.Cache.lookup!("map_#{map_id}:started", false) - def get_update_map(update, attributes), - do: - {:ok, - Enum.reduce(attributes, Map.new(), fn attribute, map -> - map |> Map.put_new(attribute, get_in(update, [Access.key(attribute)])) - end)} + def get_update_map(update, attributes) do + {:ok, + Enum.reduce(attributes, Map.new(), fn attribute, map -> + value = get_in(update, [Access.key(attribute)]) + map |> Map.put_new(attribute, value) + end)} + end defp map_options(options) do [ @@ -549,17 +554,23 @@ defmodule WandererApp.Map.Server.Impl do character_id ) do systems - |> Enum.each(fn %{ - "description" => description, - "id" => id, - "labels" => labels, - "locked" => locked, - "name" => name, - "position" => %{"x" => x, "y" => y}, - "status" => status, - "tag" => tag, - "temporary_name" => temporary_name - } -> + |> Enum.each(fn system -> + # Extract required fields with defaults for optional ones + description = Map.get(system, "description", "") + id = Map.get(system, "id") + labels = Map.get(system, "labels", []) + locked = Map.get(system, "locked", false) + name = Map.get(system, "name", "") + position = Map.get(system, "position", %{"x" => 0, "y" => 0}) + x = Map.get(position, "x", 0) + y = Map.get(position, "y", 0) + status = Map.get(system, "status", 0) + tag = Map.get(system, "tag", "") + temporary_name = Map.get(system, "temporary_name", "") + owner_type = Map.get(system, "owner_type") + owner_id = Map.get(system, "owner_id") + custom_flags = Map.get(system, "custom_flags") + solar_system_id = id |> String.to_integer() add_system( @@ -588,6 +599,21 @@ defmodule WandererApp.Map.Server.Impl do temporary_name: temporary_name }) + if owner_type || owner_id do + update_system_owner(map_id, %{ + solar_system_id: solar_system_id, + owner_type: owner_type, + owner_id: owner_id + }) + end + + if custom_flags do + update_system_custom_flags(map_id, %{ + solar_system_id: solar_system_id, + custom_flags: custom_flags + }) + end + update_system_locked(map_id, %{solar_system_id: solar_system_id, locked: locked}) update_system_labels(map_id, %{solar_system_id: solar_system_id, labels: labels}) diff --git a/lib/wanderer_app/map/server/map_server_systems_impl.ex b/lib/wanderer_app/map/server/map_server_systems_impl.ex index b36b91386..a49480699 100644 --- a/lib/wanderer_app/map/server/map_server_systems_impl.ex +++ b/lib/wanderer_app/map/server/map_server_systems_impl.ex @@ -246,6 +246,44 @@ defmodule WandererApp.Map.Server.SystemsImpl do ), do: update_system(map_id, :update_custom_name, [:custom_name], update) + def update_system_owner(map_id, update) do + require Logger + + # Convert string keys to atoms if needed + update = + case update do + %{owner_ticker: _} -> + update + + %{"owner_ticker" => ticker} -> + update + |> Map.put(:owner_ticker, ticker) + |> Map.delete("owner_ticker") + + _ -> + update + end + + # Ensure all owner fields are present + update = + update + |> Map.put_new(:owner_id, nil) + |> Map.put_new(:owner_type, nil) + |> Map.put_new(:owner_ticker, nil) + + Logger.debug(fn -> "[update_system_owner] Updating with: #{inspect(update)}" end) + + map_id + |> update_system(:update_owner, [:owner_type, :owner_id, :owner_ticker], update) + end + + def update_system_custom_flags( + map_id, + update + ) do + map_id |> update_system(:update_custom_flags, [:custom_flags], update) + end + def update_system_locked( map_id, update @@ -1075,19 +1113,20 @@ defmodule WandererApp.Map.Server.SystemsImpl do update, callback_fn \\ nil ) do + require Logger + with :ok <- WandererApp.Map.update_system_by_solar_system_id(map_id, update), {:ok, system} <- WandererApp.MapSystemRepo.get_by_map_and_solar_system_id( map_id, update.solar_system_id ), - {:ok, update_map} <- Impl.get_update_map(update, attributes) do - {:ok, updated_system} = - apply(WandererApp.MapSystemRepo, update_method, [ - system, - update_map - ]) - + {:ok, update_map} <- Impl.get_update_map(update, attributes), + {:ok, updated_system} <- + apply(WandererApp.MapSystemRepo, update_method, [ + system, + update_map + ]) do if not is_nil(callback_fn) do callback_fn.(updated_system) end diff --git a/lib/wanderer_app/repositories/map_system_repo.ex b/lib/wanderer_app/repositories/map_system_repo.ex index e8d229309..529bc99dc 100644 --- a/lib/wanderer_app/repositories/map_system_repo.ex +++ b/lib/wanderer_app/repositories/map_system_repo.ex @@ -160,6 +160,35 @@ defmodule WandererApp.MapSystemRepo do |> WandererApp.Api.MapSystem.update_custom_name(update) end + def update_owner(system, update) do + require Logger + + # Ensure we have a clean update map with all required fields + # Convert empty strings to nil for owner_ticker + ticker = + case Map.get(update, :owner_ticker) do + "" -> nil + ticker -> ticker + end + + clean_update = %{ + owner_id: Map.get(update, :owner_id), + owner_type: Map.get(update, :owner_type), + owner_ticker: ticker + } + + result = + system + |> WandererApp.Api.MapSystem.update_owner(clean_update) + + result + end + + def update_custom_flags(system, update) do + system + |> WandererApp.Api.MapSystem.update_custom_flags(update) + end + def update_labels(system, update), do: system diff --git a/lib/wanderer_app_web/api_router/routes.ex b/lib/wanderer_app_web/api_router/routes.ex index e90d0e6c9..43de42817 100644 --- a/lib/wanderer_app_web/api_router/routes.ex +++ b/lib/wanderer_app_web/api_router/routes.ex @@ -489,6 +489,20 @@ defmodule WandererAppWeb.ApiRoutes do }, # ACL Members API + %RouteSpec{ + verb: :get, + path: ~w(api v1 acls :acl_id members :member_id), + controller: WandererAppWeb.AccessListMemberAPIController, + action: :show_v1, + features: [], + metadata: %{ + auth_required: true, + rate_limit: :standard, + success_status: 200, + content_type: "application/vnd.api+json", + description: "Get a specific member from an access list" + } + }, %RouteSpec{ verb: :post, path: ~w(api v1 acls :acl_id members), diff --git a/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex b/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex index 4f9bb67f9..92cca464f 100644 --- a/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex +++ b/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex @@ -11,6 +11,15 @@ defmodule WandererAppWeb.AccessListMemberAPIController do import Ash.Query require Logger + # ------------------------------------------------------------------------ + # V1 API Actions (for compatibility with versioned API router) + # ------------------------------------------------------------------------ + + def show_v1(conn, params), do: show(conn, params) + def create_v1(conn, params), do: create(conn, params) + def update_role_v1(conn, params), do: update_role(conn, params) + def delete_v1(conn, params), do: delete(conn, params) + # ------------------------------------------------------------------------ # Inline Schemas # ------------------------------------------------------------------------ @@ -95,10 +104,101 @@ defmodule WandererAppWeb.AccessListMemberAPIController do required: ["ok"] } + @acl_member_show_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string}, + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name", "role"] + } + }, + required: ["data"] + } + # ------------------------------------------------------------------------ # ENDPOINTS # ------------------------------------------------------------------------ + @doc """ + GET /api/acls/:acl_id/members/:member_id + + Retrieves a specific ACL member by ACL ID and member external ID (EVE character/corp/alliance ID). + """ + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:show, + summary: "Get ACL Member", + description: + "Retrieves a specific ACL member identified by ACL ID and member external ID (EVE character, corporation, or alliance ID).", + parameters: [ + acl_id: [ + in: :path, + description: "Access List ID", + type: :string, + required: true + ], + member_id: [ + in: :path, + description: "Member external ID (EVE character, corporation, or alliance ID)", + type: :string, + required: true + ] + ], + responses: [ + ok: { + "ACL Member details", + "application/json", + @acl_member_show_response_schema + }, + not_found: { + "Member not found", + "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{error: %OpenApiSpex.Schema{type: :string}} + } + } + ] + ) + + def show(conn, %{"acl_id" => acl_id, "member_id" => external_id}) do + external_id_str = to_string(external_id) + + membership_query = + AccessListMember + |> Ash.Query.new() + |> filter(access_list_id == ^acl_id) + |> filter( + eve_character_id == ^external_id_str or + eve_corporation_id == ^external_id_str or + eve_alliance_id == ^external_id_str + ) + + case Ash.read(membership_query) do + {:ok, [membership]} -> + json(conn, %{data: member_to_json(membership)}) + + {:ok, []} -> + conn + |> put_status(:not_found) + |> json(%{error: "Membership not found for given ACL and external id"}) + + {:error, error} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: inspect(error)}) + end + end + @doc """ POST /api/acls/:acl_id/members diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex index 66a8a786e..4433126c8 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex @@ -8,6 +8,10 @@ defmodule WandererAppWeb.MapCharactersEventHandler do alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} + @refresh_delay 100 + # Rate limiting: 5 minutes in milliseconds + @clear_all_cooldown 5 * 60 * 1000 + def handle_server_event(%{event: :character_added, payload: character}, socket) do socket |> MapEventHandler.push_map_event( @@ -66,10 +70,19 @@ defmodule WandererAppWeb.MapCharactersEventHandler do } } = socket ) do + # Get all ready characters for this map from all users + all_ready_characters = get_all_ready_characters_for_map(map_id) + characters = map_id |> WandererApp.Map.list_characters() - |> Enum.map(&map_ui_character/1) + |> Enum.map(fn character -> + ui_character = map_ui_character(character) + # Add ready status to character data + is_ready = character.eve_id in all_ready_characters + ui_character_with_ready = Map.put(ui_character, :ready, is_ready) + ui_character_with_ready + end) socket |> MapEventHandler.push_map_event( @@ -126,6 +139,22 @@ defmodule WandererAppWeb.MapCharactersEventHandler do ) end + def handle_server_event(%{event: :ready_characters_updated, payload: payload}, socket) do + socket + |> MapEventHandler.push_map_event( + "ready_characters_updated", + payload + ) + end + + def handle_server_event(%{event: :all_ready_characters_cleared, payload: payload}, socket) do + socket + |> MapEventHandler.push_map_event( + "all_ready_characters_cleared", + payload + ) + end + def handle_server_event(event, socket), do: MapCoreEventHandler.handle_server_event(event, socket) @@ -150,10 +179,45 @@ defmodule WandererAppWeb.MapCharactersEventHandler do } } = socket ) do - {:ok, tracking_data} = - WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + case WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) do + {:ok, tracking_data} -> + {:reply, %{data: tracking_data}, socket} + + {:error, reason} -> + Logger.error("Failed to build tracking data: #{inspect(reason)}") + + {:reply, %{data: %{characters: [], main: nil, following: nil, ready_characters: []}}, + socket} + end + end + + def handle_ui_event( + "getAllReadyCharacters", + _event, + %{ + assigns: %{ + map_id: map_id + } + } = socket + ) do + try do + case build_all_ready_characters_data(map_id) do + {:ok, ready_characters_data} -> + {:reply, %{data: ready_characters_data}, socket} - {:reply, %{data: tracking_data}, socket} + {:error, reason} -> + Logger.error("Failed to build all ready characters data: #{inspect(reason)}") + {:reply, %{data: %{characters: []}}, socket} + end + rescue + error -> + Logger.error("Exception in getAllReadyCharacters: #{inspect(error)}") + {:reply, %{data: %{characters: []}}, socket} + catch + :exit, reason -> + Logger.error("Exit in getAllReadyCharacters: #{inspect(reason)}") + {:reply, %{data: %{characters: []}}, socket} + end end def handle_ui_event( @@ -306,6 +370,46 @@ defmodule WandererAppWeb.MapCharactersEventHandler do end end + def handle_ui_event( + "updateReadyCharacters", + %{"ready_character_eve_ids" => ready_character_eve_ids}, + %{assigns: %{map_id: map_id, current_user: %{id: current_user_id}}} = socket + ) do + # Not a clear all operation, proceed normally + perform_update_ready_characters( + ready_character_eve_ids, + map_id, + current_user_id, + socket, + false + ) + end + + def handle_ui_event( + "clearAllReadyCharacters", + _event, + %{assigns: %{map_id: map_id, current_user: %{id: current_user_id}}} = socket + ) do + # Check rate limiting for clear all operation + case check_clear_all_rate_limit(map_id) do + {:ok, remaining_cooldown} when remaining_cooldown > 0 -> + {:reply, + %{ + error: "rate_limited", + message: "Clear all function is on cooldown", + remaining_cooldown: remaining_cooldown + }, socket} + + {:ok, _} -> + # Rate limit passed, continue with the operation + perform_clear_all_ready_characters(map_id, current_user_id, socket) + + {:error, reason} -> + Logger.error("Rate limit check failed: #{inspect(reason)}") + {:reply, %{error: "internal_error", message: "Failed to check rate limit"}, socket} + end + end + def handle_ui_event( "startTracking", %{"character_eve_id" => character_eve_id}, @@ -325,6 +429,193 @@ defmodule WandererAppWeb.MapCharactersEventHandler do def handle_ui_event(event, body, socket), do: MapCoreEventHandler.handle_ui_event(event, body, socket) + # Private functions + + defp perform_clear_all_ready_characters(map_id, current_user_id, socket) do + try do + # Get all user settings for this map using Ash action + {:ok, map_user_settings} = WandererApp.Api.MapUserSettings.read_by_map(map_id) + + # Clear ready characters for all users + results = + Enum.map(map_user_settings, fn user_setting -> + case WandererApp.Api.MapUserSettings.update_ready_characters(user_setting, %{ + ready_characters: [] + }) do + {:ok, _updated_settings} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to clear ready characters for user #{user_setting.user_id}: #{inspect(reason)}" + ) + + {:error, reason} + end + end) + + # Check if all operations succeeded + failed_operations = Enum.filter(results, &(&1 != :ok)) + + if Enum.empty?(failed_operations) do + # Set rate limit for clear all operation + set_clear_all_rate_limit(map_id) + + # Broadcast to all users that ready characters have been cleared + WandererAppWeb.Endpoint.broadcast!( + "map:#{map_id}", + "all_ready_characters_cleared", + %{ + cleared_by_user_id: current_user_id + } + ) + + # Build and return updated tracking data for current user + {:ok, tracking_data} = + WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + + # Send characters_updated event to update all character data including ready status + Process.send_after(self(), %{event: :characters_updated}, @refresh_delay + 10) + + {:reply, %{data: tracking_data}, socket} + else + Logger.error("Some clear operations failed: #{inspect(failed_operations)}") + {:reply, %{error: "Failed to clear some ready characters"}, socket} + end + rescue + error -> + Logger.error("Exception in clear all ready characters: #{inspect(error)}") + {:reply, %{error: "Internal error while clearing ready characters"}, socket} + end + end + + defp perform_update_ready_characters( + ready_character_eve_ids, + map_id, + current_user_id, + socket, + is_clear_all + ) do + # Validate ready characters exist, are owned by user, and are tracked + {:ok, valid_ready_characters} = + validate_ready_characters(map_id, current_user_id, ready_character_eve_ids) + + # Get or create user settings and update ready characters + {:ok, map_user_settings} = WandererApp.MapUserSettingsRepo.get(map_id, current_user_id) + + result = + case map_user_settings do + nil -> + # Create new settings if none exist, then update with ready characters + case WandererApp.Api.MapUserSettings.create(%{ + map_id: map_id, + user_id: current_user_id, + settings: "{}" + }) do + {:ok, new_settings} -> + # Now update with ready characters + case WandererApp.Api.MapUserSettings.update_ready_characters(new_settings, %{ + ready_characters: valid_ready_characters + }) do + {:ok, _updated_settings} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to update ready characters on new settings: #{inspect(reason)}" + ) + + {:error, "Failed to save ready characters"} + end + + {:error, reason} -> + Logger.error("Failed to create user settings: #{inspect(reason)}") + {:error, "Failed to create user settings"} + end + + existing_settings -> + # Update existing settings + case WandererApp.Api.MapUserSettings.update_ready_characters(existing_settings, %{ + ready_characters: valid_ready_characters + }) do + {:ok, _updated_settings} -> + :ok + + {:error, reason} -> + Logger.error("Failed to update ready characters: #{inspect(reason)}") + {:error, "Failed to save ready characters"} + end + end + + case result do + :ok -> + # If this was a clear all operation, update the rate limit cache + if is_clear_all do + set_clear_all_rate_limit(map_id) + end + + # Broadcast ready status changes to other users in the map + broadcast_ready_status_change(map_id, current_user_id, valid_ready_characters) + + # Build and return updated tracking data immediately + {:ok, tracking_data} = + WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + + # Send characters_updated event to update all character data including ready status + Process.send_after(self(), %{event: :characters_updated}, @refresh_delay + 10) + + {:reply, %{data: tracking_data}, socket} + + {:error, reason} -> + {:noreply, socket |> put_flash(:error, reason)} + end + end + + defp check_clear_all_rate_limit(map_id) do + cache_key = "map:#{map_id}:clear_all_ready_last_used" + + case WandererApp.Cache.get(cache_key) do + nil -> + # No previous clear all operation recorded + {:ok, 0} + + last_clear_time when is_integer(last_clear_time) -> + current_time = System.system_time(:millisecond) + time_since_last_clear = current_time - last_clear_time + remaining_cooldown = max(0, @clear_all_cooldown - time_since_last_clear) + {:ok, remaining_cooldown} + + _ -> + # Invalid cache value, treat as no rate limit + {:ok, 0} + end + rescue + error -> + Logger.error("Error checking clear all rate limit: #{inspect(error)}") + {:error, :cache_error} + end + + defp set_clear_all_rate_limit(map_id) do + cache_key = "map:#{map_id}:clear_all_ready_last_used" + current_time = System.system_time(:millisecond) + + # Set with TTL slightly longer than the cooldown to ensure cleanup + ttl_seconds = div(@clear_all_cooldown, 1000) + 60 + + case WandererApp.Cache.put(cache_key, current_time, ttl: ttl_seconds) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Failed to set clear all rate limit: #{inspect(reason)}") + {:error, reason} + end + rescue + error -> + Logger.error("Error setting clear all rate limit: #{inspect(error)}") + {:error, :cache_error} + end + def map_ui_character(character), do: character @@ -341,14 +632,29 @@ defmodule WandererAppWeb.MapCharactersEventHandler do |> Map.put(:alliance_ticker, Map.get(character, :alliance_ticker, "")) |> Map.put_new(:ship, WandererApp.Character.get_ship(character)) |> Map.put_new(:location, get_location(character)) + |> Map.put_new(:tracking_paused, character |> Map.get(:tracking_paused, false)) defp get_location(character), do: %{ - solar_system_id: character.solar_system_id, - structure_id: character.structure_id, - station_id: character.station_id + solar_system_id: Map.get(character, :solar_system_id), + structure_id: Map.get(character, :structure_id), + station_id: Map.get(character, :station_id) } + # Gets all ready characters for a map from all users' settings. + # Returns a list of character EVE IDs that are marked as ready. + defp get_all_ready_characters_for_map(map_id) do + case WandererApp.MapUserSettingsRepo.get_by_map(map_id) do + {:ok, settings_list} -> + settings_list + |> Enum.flat_map(fn setting -> setting.ready_characters || [] end) + |> Enum.uniq() + + {:error, _reason} -> + [] + end + end + def needs_tracking_setup?( only_tracked_characters, characters, @@ -417,4 +723,154 @@ defmodule WandererAppWeb.MapCharactersEventHandler do !is_tracked end) end + + # Validates that the provided character EVE IDs are valid. + # Returns {:ok, valid_character_eve_ids} or {:error, reason}. + defp validate_ready_characters(map_id, current_user_id, ready_character_eve_ids) do + with {:ok, user_characters_list} <- + WandererApp.Api.Character.active_by_user(%{user_id: current_user_id}), + user_character_ids = Enum.map(user_characters_list, & &1.id), + {:ok, character_settings} <- + WandererApp.MapCharacterSettingsRepo.get_by_map_filtered(map_id, user_character_ids) do + # Get valid user character EVE IDs + user_character_eve_ids = user_characters_list |> Enum.map(& &1.eve_id) |> MapSet.new() + + # Get tracked character IDs + tracked_character_ids = + character_settings + |> Enum.filter(& &1.tracked) + |> Enum.map(& &1.character_id) + |> MapSet.new() + + # Find tracked characters that match user characters + tracked_user_characters = + user_characters_list + |> Enum.filter(&MapSet.member?(tracked_character_ids, &1.id)) + |> Enum.map(& &1.eve_id) + |> MapSet.new() + + # Filter ready characters to only include owned, tracked characters + valid_ready_characters = + ready_character_eve_ids + |> Enum.filter(fn eve_id -> + MapSet.member?(user_character_eve_ids, eve_id) && + MapSet.member?(tracked_user_characters, eve_id) + end) + + {:ok, valid_ready_characters} + else + error -> + {:error, "Failed to validate characters: #{inspect(error)}"} + end + end + + # Broadcasts ready status changes to other users in the map. + defp broadcast_ready_status_change(map_id, current_user_id, ready_character_eve_ids) do + # Get current user info for the broadcast + {:ok, current_user} = WandererApp.Api.User.by_id(current_user_id) + + # Broadcast to all users in the map + WandererAppWeb.Endpoint.broadcast!( + "map:#{map_id}", + "ready_characters_updated", + %{ + user_id: current_user_id, + user_name: current_user.name, + ready_character_eve_ids: ready_character_eve_ids + } + ) + end + + # Builds data for all ready characters from all users in the map. + defp build_all_ready_characters_data(map_id) do + with {:ok, ready_character_eve_ids} <- get_all_ready_character_eve_ids(map_id), + {:ok, tracked_characters} <- get_tracked_characters_with_settings(map_id), + {:ok, filtered_characters} <- + filter_ready_and_tracked_characters(tracked_characters, ready_character_eve_ids), + {:ok, enriched_characters} <- enrich_character_data(filtered_characters) do + {:ok, %{characters: enriched_characters}} + else + {:error, reason} -> + Logger.error("Failed to build ready characters data: #{inspect(reason)}") + {:ok, %{characters: []}} + end + end + + defp get_all_ready_character_eve_ids(map_id) do + case WandererApp.Api.MapUserSettings.read_by_map(%{map_id: map_id}) do + {:ok, map_user_settings} -> + ready_eve_ids = + map_user_settings + |> Enum.flat_map(fn settings -> + case settings.ready_characters do + nil -> [] + ready_chars when is_list(ready_chars) -> ready_chars + _ -> [] + end + end) + |> MapSet.new() + + {:ok, ready_eve_ids} + + {:error, reason} -> + {:error, reason} + end + end + + defp get_tracked_characters_with_settings(map_id) do + case WandererApp.Api.MapCharacterSettings.read_by_map(%{map_id: map_id}) do + {:ok, map_character_settings} -> + # Load character relationships + settings_with_chars = + Enum.map(map_character_settings, fn setting -> + Ash.load!(setting, :character) + end) + + {:ok, settings_with_chars} + + {:error, reason} -> + {:error, reason} + end + end + + defp filter_ready_and_tracked_characters(settings_with_chars, ready_eve_ids) do + filtered = + settings_with_chars + |> Enum.filter(fn setting -> + char = setting.character + # Character must exist, have a user_id, be tracked, and be in ready list + char != nil && + not is_nil(char.user_id) && + setting.tracked && + MapSet.member?(ready_eve_ids, char.eve_id) + end) + |> Enum.map(fn setting -> setting.character end) + + {:ok, filtered} + end + + defp enrich_character_data(characters) do + enriched = + Enum.map(characters, fn char -> + # Get actual online status + actual_online = + case WandererApp.Character.get_character_state(char.id, false) do + {:ok, %{is_online: is_online}} when not is_nil(is_online) -> is_online + _ -> Map.get(char, :online, false) + end + + character_data = + char + |> Map.put(:online, actual_online) + |> map_ui_character() + + %{ + character: character_data, + tracked: true, + ready: true + } + end) + + {:ok, enriched} + end end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex index 2b61219c0..5c2a2cc86 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex @@ -5,6 +5,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} alias WandererApp.Map.Server.Impl + alias WandererApp.Character def handle_server_event(%{event: :add_system, payload: system}, socket) do # Schedule kill update for the new system after a short delay to allow subscription @@ -229,6 +230,87 @@ defmodule WandererAppWeb.MapSystemsEventHandler do {:noreply, socket} end + def handle_ui_event( + "update_system_owner", + %{"system_id" => sid} = params, + %{ + assigns: %{ + map_id: map_id, + current_user: current_user, + tracked_characters: tracked_characters, + user_permissions: user_permissions + } + } = socket + ) do + # Extract owner_id, owner_type, and owner_ticker from params using STRING keys + oid = Map.get(params, "owner_id") + otype = Map.get(params, "owner_type") + ticker = Map.get(params, "owner_ticker") + + # Clean up potential null/empty string values + oid = + case oid do + "null" -> nil + "" -> nil + val -> val + end + + otype = + case otype do + "null" -> nil + "" -> nil + val -> val + end + + ticker = + case ticker do + "null" -> nil + "" -> nil + val -> val + end + + if can_update_system?(:owner, user_permissions) do + system_id_int = + case sid do + id when is_integer(id) -> id + id when is_binary(id) -> String.to_integer(id) + _ -> nil + end + + if system_id_int do + WandererApp.Map.Server.update_system_owner( + map_id, + %{ + solar_system_id: system_id_int, + owner_id: oid, + owner_type: otype, + owner_ticker: ticker + } + ) + + main_character_id = + case tracked_characters do + [first | _] -> first.id + _ -> nil + end + + if main_character_id do + {:ok, _} = + WandererApp.User.ActivityTracker.track_map_event(:system_updated, %{ + character_id: main_character_id, + user_id: current_user.id, + map_id: map_id, + solar_system_id: system_id_int, + key: :owner, + value: %{owner_id: oid, owner_type: otype, ticker: ticker} + }) + end + end + end + + {:reply, %{}, socket} + end + def handle_ui_event( "update_system_" <> param, %{"system_id" => solar_system_id, "value" => value} = _event, @@ -252,6 +334,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do "locked" -> :update_system_locked "tag" -> :update_system_tag "temporary_name" -> :update_system_temporary_name + "custom_flags" -> :update_system_custom_flags "status" -> :update_system_status _ -> nil end @@ -264,6 +347,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do "locked" -> :locked "tag" -> :tag "temporary_name" -> :temporary_name + "custom_flags" -> :custom_flags "status" -> :status _ -> :none end @@ -287,7 +371,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do }) end - {:noreply, socket} + {:reply, %{}, socket} end def handle_ui_event( @@ -361,7 +445,11 @@ defmodule WandererAppWeb.MapSystemsEventHandler do {:ok, solar_system_id_int} -> case WandererApp.MapRepo.get(map_id) do {:ok, %{intel_source_map_id: source_map_id}} when not is_nil(source_map_id) -> - case WandererApp.Map.IntelSync.sync_system(map_id, source_map_id, solar_system_id_int) do + case WandererApp.Map.IntelSync.sync_system( + map_id, + source_map_id, + solar_system_id_int + ) do {:ok, updated_system} when is_map(updated_system) -> intel_fields = WandererApp.Map.IntelSync.intel_fields() @@ -390,8 +478,114 @@ defmodule WandererAppWeb.MapSystemsEventHandler do {:reply, %{success: false, error: "forbidden"}, socket} end - def handle_ui_event(event, body, socket), - do: MapCoreEventHandler.handle_ui_event(event, body, socket) + # Handle UI events for getting corporation names + def handle_ui_event("get_corporation_names", %{"search" => search}, socket) do + user_chars = socket.assigns.current_user.characters + + response = + case search_corporation_names(user_chars, search) do + {:ok, results} -> %{results: results} + _ -> %{results: []} + end + + {:reply, response, socket} + end + + # Handle UI events for getting alliance names + def handle_ui_event("get_alliance_names", %{"search" => search}, socket) do + user_chars = socket.assigns.current_user.characters + + response = + case search_alliance_names(user_chars, search) do + {:ok, results} -> %{results: results} + _ -> %{results: []} + end + + {:reply, response, socket} + end + + # Handle UI events for getting corporation ticker + def handle_ui_event("get_corporation_ticker", %{"corp_id" => corp_id}, socket) do + case WandererApp.Esi.get_corporation_info(corp_id) do + {:ok, %{"ticker" => ticker}} -> + {:reply, %{ticker: ticker}, socket} + + _error -> + {:reply, %{ticker: nil}, socket} + end + end + + # Handle UI events for getting alliance ticker + def handle_ui_event("get_alliance_ticker", %{"alliance_id" => alliance_id}, socket) do + case WandererApp.Esi.get_alliance_info(alliance_id) do + {:ok, %{"ticker" => ticker}} -> + {:reply, %{ticker: ticker}, socket} + + _error -> + {:reply, %{ticker: nil}, socket} + end + end + + # Fallback for update_system_owner when map isn't fully loaded + def handle_ui_event( + "update_system_owner", + _params, + %{assigns: %{map_loaded?: false}} = socket + ) do + Logger.debug("[MapSystemsEventHandler] Ignoring update_system_owner - map not loaded yet") + {:reply, %{}, socket} + end + + def handle_ui_event( + "update_system_owner", + _params, + %{assigns: assigns} = socket + ) + when not is_map_key(assigns, :map_id) or not is_map_key(assigns, :tracked_characters) do + Logger.debug( + "[MapSystemsEventHandler] Ignoring update_system_owner - missing required assigns" + ) + + {:reply, %{}, socket} + end + + # Fallback for update_system_custom_flags when map isn't fully loaded + def handle_ui_event( + "update_system_custom_flags", + _params, + %{assigns: %{map_loaded?: false}} = socket + ) do + Logger.debug( + "[MapSystemsEventHandler] Ignoring update_system_custom_flags - map not loaded yet" + ) + + {:reply, %{}, socket} + end + + def handle_ui_event( + "update_system_custom_flags", + _params, + %{assigns: assigns} = socket + ) + when not is_map_key(assigns, :map_id) or not is_map_key(assigns, :main_character_id) do + Logger.debug( + "[MapSystemsEventHandler] Ignoring update_system_custom_flags - missing required assigns" + ) + + {:reply, %{}, socket} + end + + # Catch-all for UI events NOT handled by specific clauses above + def handle_ui_event(event, params, socket) do + Logger.warning( + "[MapSystemsEventHandler - UNMATCHED] Received event: #{inspect(event)}, Params: #{inspect(params)}, Assigns: #{inspect(socket.assigns)}" + ) + + # Forward to the core event handler as a fallback + MapCoreEventHandler.handle_ui_event(event, params, socket) + end + + # --- Private helpers --- def map_system( %{ @@ -438,4 +632,90 @@ defmodule WandererAppWeb.MapSystemsEventHandler do }) defp update_system_position(_map_id, _position), do: :ok + + defp search_corporation_names([], _search), do: {:ok, []} + + defp search_corporation_names([first_char | _], search) when is_binary(search) do + if String.length(search) < 3 do + {:ok, []} + else + result = + Character.search(first_char.id, params: [search: search, categories: "corporation"]) + + case result do + {:ok, results} -> + formatted_results = + Enum.map(results, fn item -> + name = Map.get(item, :label, "") + corp_id = Map.get(item, :value, "") + + ticker = + case WandererApp.Esi.get_corporation_info(corp_id) do + {:ok, %{"ticker" => ticker}} -> ticker + _ -> "" + end + + formatted_label = if ticker && ticker != "", do: "[#{ticker}] #{name}", else: name + + Map.merge(item, %{ + formatted: formatted_label, + name: name, + ticker: ticker, + id: item.value, + type: "corp" + }) + end) + + {:ok, formatted_results} + + other -> + other + end + end + end + + defp search_corporation_names(_user_chars, _search), do: {:ok, []} + + defp search_alliance_names([], _search), do: {:ok, []} + + defp search_alliance_names([first_char | _], search) when is_binary(search) do + if String.length(search) < 3 do + {:ok, []} + else + result = + Character.search(first_char.id, params: [search: search, categories: "alliance"]) + + case result do + {:ok, results} -> + formatted_results = + Enum.map(results, fn item -> + name = Map.get(item, :label, "") + alliance_id = Map.get(item, :value, "") + + ticker = + case WandererApp.Esi.get_alliance_info(alliance_id) do + {:ok, %{"ticker" => ticker}} -> ticker + _ -> "" + end + + formatted_label = if ticker && ticker != "", do: "[#{ticker}] #{name}", else: name + + Map.merge(item, %{ + formatted: formatted_label, + name: name, + ticker: ticker, + id: item.value, + type: "alliance" + }) + end) + + {:ok, formatted_results} + + other -> + other + end + end + end + + defp search_alliance_names(_user_chars, _search), do: {:ok, []} end diff --git a/lib/wanderer_app_web/live/map/map_event_handler.ex b/lib/wanderer_app_web/live/map/map_event_handler.ex index 72ae4d8be..9862653ea 100644 --- a/lib/wanderer_app_web/live/map/map_event_handler.ex +++ b/lib/wanderer_app_web/live/map/map_event_handler.ex @@ -25,15 +25,20 @@ defmodule WandererAppWeb.MapEventHandler do :present_characters_updated, :refresh_user_characters, :show_tracking, - :untrack_character + :untrack_character, + :ready_characters_updated, + :all_ready_characters_cleared ] @map_characters_ui_events [ "getCharacterInfo", "getCharactersTrackingInfo", + "getAllReadyCharacters", + "clearAllReadyCharacters", "updateCharacterTracking", "updateFollowingCharacter", "updateMainCharacter", + "updateReadyCharacters", "startTracking" ] @@ -58,6 +63,15 @@ defmodule WandererAppWeb.MapEventHandler do "update_system_tag", "update_system_temporary_name", "update_system_status", + "get_user_hubs", + "add_user_hub", + "delete_user_hub", + "update_system_owner", + "get_corporation_names", + "get_corporation_ticker", + "get_alliance_names", + "get_alliance_ticker", + "update_system_custom_flags", "manual_paste_systems_and_connections", "sync_intel" ] @@ -140,9 +154,7 @@ defmodule WandererAppWeb.MapEventHandler do @map_structures_ui_events [ "update_structures", - "get_structures", - "get_corporation_names", - "get_corporation_ticker" + "get_structures" ] @map_kills_events [ @@ -323,8 +335,9 @@ defmodule WandererAppWeb.MapEventHandler do def map_ui_character_stat(nil), do: nil - def map_ui_character_stat(character), - do: + def map_ui_character_stat(character) do + # Take only the basic fields first + base_character = character |> Map.take([ :eve_id, @@ -332,9 +345,32 @@ defmodule WandererAppWeb.MapEventHandler do :corporation_id, :corporation_ticker, :alliance_id, - :alliance_ticker + :alliance_ticker, + :ship_name, + :online ]) + # Add optional fields only if they're loaded (not Ash.NotLoaded) + base_character = + base_character + |> maybe_add_field(character, :solar_system_id) + |> maybe_add_field(character, :structure_id) + |> maybe_add_field(character, :station_id) + |> maybe_add_field(character, :ship) + + # Add ship type information + ship_info = WandererApp.Character.get_ship(character) + base_character |> Map.put(:ship_info, ship_info) + end + + defp maybe_add_field(map, source, field) do + case Map.get(source, field) do + %Ash.NotLoaded{} -> map + nil -> map + value -> Map.put(map, field, value) + end + end + def map_ui_connection( %{ solar_system_source: solar_system_source, @@ -372,9 +408,18 @@ defmodule WandererAppWeb.MapEventHandler do temporary_name: temporary_name, status: status, visible: visible - } = _system, - include_static_data? \\ true + } = system, + _include_static_data? \\ true ) do + system_static_info = get_system_static_info(solar_system_id) + + system_signatures = + system_id + |> WandererAppWeb.MapSignaturesEventHandler.get_system_signatures() + |> Enum.filter(fn signature -> + is_nil(signature.linked_system) && signature.group == "Wormhole" + end) + comments_count = system_id |> WandererApp.Maps.get_system_comments_activity() @@ -386,30 +431,36 @@ defmodule WandererAppWeb.MapEventHandler do 0 end - system_info = - %{ - id: "#{solar_system_id}", - position: %{x: position_x, y: position_y}, - description: description, - name: name, - labels: labels, - locked: locked, - linked_sig_eve_id: linked_sig_eve_id, - status: status, - tag: tag, - temporary_name: temporary_name, - comments_count: comments_count, - visible: visible - } + result = %{ + id: "#{solar_system_id}", + position: %{x: position_x, y: position_y}, + description: description, + name: name, + system_static_info: system_static_info, + system_signatures: system_signatures, + labels: labels, + locked: locked, + linked_sig_eve_id: linked_sig_eve_id, + status: status, + tag: tag, + temporary_name: temporary_name, + comments_count: comments_count, + visible: visible + } + + Map.merge(result, zoo_system_fields(system)) + end - system_info = - if include_static_data? do - system_info |> Map.merge(%{system_static_info: get_system_static_info(solar_system_id)}) - else - system_info - end + @zoo_system_keys [:owner_type, :owner_id, :owner_ticker, :custom_flags] - system_info + defp zoo_system_fields(system) do + system + |> Map.take(@zoo_system_keys) + |> Enum.reduce(%{}, fn + {_key, nil}, acc -> acc + {_key, ""}, acc -> acc + {key, value}, acc -> Map.put(acc, key, value) + end) end def map_ui_system_static_info(nil), do: %{} diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index 4eab48b9b..82db55173 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -338,6 +338,7 @@ defmodule WandererAppWeb.Router do get "/:id", MapAccessListAPIController, :show put "/:id", MapAccessListAPIController, :update post "/:acl_id/members", AccessListMemberAPIController, :create + get "/:acl_id/members/:member_id", AccessListMemberAPIController, :show put "/:acl_id/members/:member_id", AccessListMemberAPIController, :update_role delete "/:acl_id/members/:member_id", AccessListMemberAPIController, :delete end From 1b4a7e2aaeb04a8245707d26c550e101ece7be82 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sat, 8 Aug 2026 17:59:17 +0000 Subject: [PATCH 13/94] zoo(feat): add discord webhook and notification resources --- .../api/map_discord_notification.ex | 198 ++++++++ lib/wanderer_app/api/map_discord_webhook.ex | 344 +++++++++++++ ...01234058_add_map_discord_notifications.exs | 55 +++ ...0803202833_create_map_discord_webhooks.exs | 54 ++ .../20260803210357_split_discord_webhooks.exs | 183 +++++++ .../20260801234059.json | 200 ++++++++ .../20260803210357.json | 155 ++++++ .../20260803202833.json | 189 +++++++ .../api/map_discord_notification_test.exs | 249 ++++++++++ test/unit/api/map_discord_webhook_test.exs | 462 ++++++++++++++++++ .../migrations/discord_webhook_split_test.exs | 271 ++++++++++ 11 files changed, 2360 insertions(+) create mode 100644 lib/wanderer_app/api/map_discord_notification.ex create mode 100644 lib/wanderer_app/api/map_discord_webhook.ex create mode 100644 priv/repo/migrations/20260801234058_add_map_discord_notifications.exs create mode 100644 priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs create mode 100644 priv/repo/migrations/20260803210357_split_discord_webhooks.exs create mode 100644 priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json create mode 100644 priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json create mode 100644 priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json create mode 100644 test/unit/api/map_discord_notification_test.exs create mode 100644 test/unit/api/map_discord_webhook_test.exs create mode 100644 test/unit/repo/migrations/discord_webhook_split_test.exs diff --git a/lib/wanderer_app/api/map_discord_notification.ex b/lib/wanderer_app/api/map_discord_notification.ex new file mode 100644 index 000000000..a461ef4f1 --- /dev/null +++ b/lib/wanderer_app/api/map_discord_notification.ex @@ -0,0 +1,198 @@ +defmodule WandererApp.Api.MapDiscordNotification do + @moduledoc """ + Per-map Discord kill-notification policy. + + Exactly one row per map. Destinations live in `MapDiscordWebhook` children — + this row holds only what applies to the map as a whole: the kill switch, + wormhole-only filtering, excluded systems, and focus corporations. + """ + + use Ash.Resource, + domain: WandererApp.Api, + data_layer: AshPostgres.DataLayer + + postgres do + repo(WandererApp.Repo) + table("map_discord_notifications_v1") + + references do + reference :map, on_delete: :delete + end + end + + code_interface do + define(:create, action: :create) + define(:update, action: :update) + define(:destroy, action: :destroy) + define(:by_id, get_by: [:id], action: :read) + define(:by_map, action: :by_map, args: [:map_id]) + end + + actions do + default_accept [:map_id, :enabled?, :wh_only, :excluded_systems, :focus_corp_ids] + + defaults [:read] + + # Custom destroy, following map_webhook_subscription.ex:51-58. The default + # destroy would leave a stale cache entry AND leave the delivery workers + # draining their queues into webhooks the user just removed. + destroy :destroy do + primary? true + require_atomic? false + + # The webhook ids MUST be captured before the delete runs. PostgreSQL + # executes ON DELETE CASCADE as a referential action of the DELETE + # statement itself, not at commit, so by the time an after_action hook + # runs the child rows are already gone and `Ash.load(record, :webhooks)` + # returns an empty list. That failure is silent: no error, no stopped + # workers, and queued messages keep posting to webhooks the user just + # removed. + # `stash_webhook_ids/2` must stay in `before_action` for the reason above. + # The cleanup, though, runs `after_transaction`: an `after_action` hook + # fires while the DELETE is still uncommitted, so a killmail arriving in + # that window reloads the configuration, still reads the pre-delete rows + # and re-caches them for the full TTL — kills keep posting to webhooks the + # user just removed. On rollback it would also have stopped the workers + # and evicted the cache for a policy that still exists. + change before_action(&__MODULE__.stash_webhook_ids/2) + change after_transaction(&__MODULE__.after_destroy/3) + end + + # Creates the policy row and its :system destination in one transaction. + # The "a system webhook always exists" invariant cannot be declared — the + # child's unique identity gives at most one webhook per role, not at least + # one — so it is enforced here: either both rows exist or neither does. + create :create do + primary? true + argument :webhook_url, :string, allow_nil?: false + + # `manage_relationship`'s `transform:` option isn't available on the + # installed Ash version (3.9.0) — its opts schema has no such key. This + # explicit form builds the input map itself instead: one :system child, + # written in the same transaction as the parent. + change fn changeset, _context -> + Ash.Changeset.manage_relationship( + changeset, + :webhooks, + [%{webhook_url: Ash.Changeset.get_argument(changeset, :webhook_url), role: :system}], + type: :create + ) + end + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + update :update do + primary? true + require_atomic? false + + # Explicit, so `default_accept` cannot expose `:map_id`: re-parenting a + # notification would move it and its webhook children to another map. + accept [:enabled?, :wh_only, :excluded_systems, :focus_corp_ids] + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + read :by_map do + argument :map_id, :uuid, allow_nil?: false + get? true + filter expr(map_id == ^arg(:map_id)) + + # Routing reads the cached value, and the cache stores whatever by_map + # returned — so the webhooks must be loaded here or routing sees + # %Ash.NotLoaded{} instead of destinations. + prepare build(load: [:webhooks]) + end + end + + attributes do + uuid_primary_key :id + + # The user-facing kill switch for the whole map. This stays on the parent + # even though each webhook now has its own enabled? flag: the two mean + # different things — this one is intent, the child's is destination health — + # and map-level intent cannot be inferred from the children. + attribute :enabled?, :boolean, default: true, allow_nil?: false + attribute :wh_only, :boolean, default: true, allow_nil?: false + + attribute :excluded_systems, {:array, :integer} do + default [] + allow_nil? false + end + + attribute :focus_corp_ids, {:array, :integer} do + default [] + allow_nil? false + end + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + relationships do + belongs_to :map, WandererApp.Api.Map do + attribute_writable? true + allow_nil? false + end + + has_many :webhooks, WandererApp.Api.MapDiscordWebhook do + destination_attribute :notification_id + end + end + + identities do + identity :unique_map_id, [:map_id] + end + + # Invalidation MUST run after the transaction, not after the action. `create` + # writes the parent and its :system child in one transaction, so an + # after_action hook drops the cache entry while both rows are still + # uncommitted. A killmail arriving in that window reloads the config, reads + # pre-commit state, finds nothing and caches the NEGATIVE `:none` marker, + # which then sticks for the cache's 5-minute TTL — a map the user just + # configured posts nothing for five minutes, with no error anywhere. The same + # window on an update re-caches the old value. + # + # On rollback there is nothing to invalidate: the error result passes through + # untouched so a failed write cannot evict a still-correct cache entry. + @doc false + def invalidate_cache(_changeset, {:ok, record}, _context) do + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(record.map_id) + {:ok, record} + end + + def invalidate_cache(_changeset, other, _context), do: other + + @doc false + def stash_webhook_ids(changeset, _context) do + ids = + case Ash.load(changeset.data, :webhooks) do + {:ok, %{webhooks: webhooks}} when is_list(webhooks) -> Enum.map(webhooks, & &1.id) + _ -> [] + end + + Ash.Changeset.put_context(changeset, :webhook_ids, ids) + end + + @doc false + def after_destroy(changeset, {:ok, record}, _context) do + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(record.map_id) + + # Stop every destination's delivery worker: without this, anything already + # queued keeps posting to webhooks the user has just removed. The ids come + # from the changeset context because the FK cascade has already deleted the + # child rows by the time this hook runs — reading them here would return an + # empty list and quietly stop nothing. + changeset.context + |> Map.get(:webhook_ids, []) + |> Enum.each(fn id -> + WandererApp.ExternalEvents.Discord.WorkerSupervisor.stop_worker(id) + end) + + {:ok, record} + end + + # Rollback: the rows still exist, so neither the cache nor the workers may be + # touched. The error passes through untouched. + def after_destroy(_changeset, other, _context), do: other +end diff --git a/lib/wanderer_app/api/map_discord_webhook.ex b/lib/wanderer_app/api/map_discord_webhook.ex new file mode 100644 index 000000000..9b6e52f44 --- /dev/null +++ b/lib/wanderer_app/api/map_discord_webhook.ex @@ -0,0 +1,344 @@ +defmodule WandererApp.Api.MapDiscordWebhook do + @moduledoc """ + One Discord destination belonging to a `MapDiscordNotification`. + + The parent row holds per-map policy; each child row holds one webhook URL and + that destination's delivery health. Splitting them means a dead character + channel disables only itself — before the split, a single `consecutive_failures` + counter on the parent would have switched off system-kill notifications too. + + The webhook URL is a credential — anyone holding it can post arbitrary messages + to the channel — so it is encrypted at rest and never rendered back in full. + """ + + use Ash.Resource, + domain: WandererApp.Api, + data_layer: AshPostgres.DataLayer, + extensions: [AshCloak] + + require Logger + + @discord_hosts ["discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com"] + + # Mirrors `WebhookDispatcher`'s threshold (webhook_dispatcher.ex:32): a run of + # 10 consecutive failures disables this destination. Only a 404 bypasses this. + @max_consecutive_failures 10 + + # Matches the :last_error attribute's max_length constraint, so an + # unexpectedly long error message is truncated rather than rejected. + @max_error_length 500 + + postgres do + repo(WandererApp.Repo) + table("map_discord_webhooks_v1") + + references do + reference :notification, on_delete: :delete + end + end + + cloak do + vault(WandererApp.Vault) + attributes([:webhook_url]) + decrypt_by_default([:webhook_url]) + end + + code_interface do + define(:create, action: :create) + define(:update, action: :update) + define(:destroy, action: :destroy) + define(:by_id, get_by: [:id], action: :read) + define(:by_notification, action: :by_notification, args: [:notification_id]) + define(:set_enabled, action: :set_enabled) + define(:record_success, action: :record_success) + define(:record_failure, action: :record_failure, args: [:error]) + define(:disable, action: :disable, args: [:error]) + end + + actions do + default_accept [:notification_id, :role, :webhook_url, :enabled?] + + defaults [:read] + + create :create do + primary? true + validate {__MODULE__.ValidateWebhookUrl, []} + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + update :update do + primary? true + require_atomic? false + # NOT `default_accept`: that would let a caller re-parent a webhook by + # passing `notification_id`, moving the credential onto another map. It + # would also defeat `do_invalidate/1`, which resolves the notification + # from the record *after* the write and so would evict only the new map's + # cache — the old map would keep routing to a destination it no longer + # owns for the rest of the TTL. `role` is immutable for the same reason: + # the unique (notification_id, role) identity is what makes "the system + # destination" addressable. + accept [:webhook_url, :enabled?] + validate {__MODULE__.ValidateWebhookUrl, []} + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + # Custom destroy, following the destroy action on + # `WandererApp.Api.MapDiscordNotification`. The default + # destroy would leave a stale cache entry AND leave this destination's + # delivery worker draining its queue into a webhook the user just removed. + # + # after_transaction for the same reason `invalidate_cache/3` uses it (see + # the comment above that function), plus one specific to destroy: an + # after_action hook would stop the delivery worker *before* the commit, so + # a rolled-back destroy would leave the row alive with its worker killed. + # Unlike the PARENT resource's destroy — which must stash its children's + # ids before PostgreSQL runs the FK cascade inside the DELETE — this hook + # needs nothing but the record it is handed. + destroy :destroy do + primary? true + require_atomic? false + + change after_transaction(&__MODULE__.after_destroy/3) + end + + read :by_notification do + argument :notification_id, :uuid, allow_nil?: false + filter expr(notification_id == ^arg(:notification_id)) + end + + update :set_enabled do + require_atomic? false + accept [:enabled?] + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + # Deliberately the ONE health action with no cache invalidation: it fires on + # every successful delivery, so evicting here would drop the routing cache on + # the hot path and defeat it. None of the four attributes below feeds a + # routing decision — routing reads `enabled?`, which this never touches. The + # cost is a `last_delivery_at` in the settings UI that can lag by one TTL. + update :record_success do + require_atomic? false + accept [] + + change set_attribute(:last_delivery_at, &DateTime.utc_now/0) + change set_attribute(:consecutive_failures, 0) + change set_attribute(:last_error, nil) + change set_attribute(:last_error_at, nil) + end + + # Increments the counter from the value re-read inside the change rather + # than from a possibly-stale in-memory copy, and disables this destination + # once the run reaches @max_consecutive_failures. + # + # This read-then-write is NOT atomic across nodes: two concurrent deliveries + # on separate nodes could each read N and write N+1, losing an increment. + # That is safe under the single-delivery-node assumption documented in the + # spec (one worker per webhook, one node), and the failure mode is benign — a + # webhook disables slightly later than it should. If the app is ever + # clustered, replace this with an atomic SQL increment. + update :record_failure do + require_atomic? false + accept [] + argument :error, :string, allow_nil?: false + + change fn changeset, _ctx -> + current = + case Ash.get(__MODULE__, changeset.data.id) do + {:ok, fresh} -> fresh.consecutive_failures || 0 + _ -> Ash.Changeset.get_data(changeset, :consecutive_failures) || 0 + end + + next = current + 1 + + changeset = + changeset + |> Ash.Changeset.change_attribute(:consecutive_failures, next) + |> Ash.Changeset.change_attribute( + :last_error, + changeset |> Ash.Changeset.get_argument(:error) |> String.slice(0, @max_error_length) + ) + |> Ash.Changeset.change_attribute(:last_error_at, DateTime.utc_now()) + + if next >= @max_consecutive_failures do + Ash.Changeset.change_attribute(changeset, :enabled?, false) + else + changeset + end + end + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + # Immediate disable, used only for a 404 (webhook deleted upstream, will + # never recover). Everything else goes through record_failure's threshold. + update :disable do + require_atomic? false + accept [] + argument :error, :string, allow_nil?: false + + change set_attribute(:enabled?, false) + change set_attribute(:last_error_at, &DateTime.utc_now/0) + + change fn changeset, _ctx -> + Ash.Changeset.change_attribute( + changeset, + :last_error, + changeset |> Ash.Changeset.get_argument(:error) |> String.slice(0, @max_error_length) + ) + end + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + end + + attributes do + uuid_primary_key :id + + attribute :role, :atom do + allow_nil? false + constraints one_of: [:system, :character] + end + + attribute :webhook_url, :string do + allow_nil? false + sensitive? true + constraints max_length: 2000 + end + + attribute :enabled?, :boolean, default: true, allow_nil?: false + + attribute :last_delivery_at, :utc_datetime + attribute :last_error, :string, constraints: [max_length: @max_error_length] + attribute :last_error_at, :utc_datetime + attribute :consecutive_failures, :integer, default: 0, allow_nil?: false + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + relationships do + belongs_to :notification, WandererApp.Api.MapDiscordNotification do + attribute_writable? true + allow_nil? false + end + end + + identities do + identity :unique_notification_role, [:notification_id, :role] + end + + @doc """ + Returns true when the URL is a syntactically valid Discord webhook endpoint. + Exposed so the LiveView form can validate before submitting. + """ + def valid_webhook_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: "https", host: host, path: path} when is_binary(host) and is_binary(path) -> + # Hostnames are case-insensitive and `URI.parse/1` returns the host + # exactly as typed, so a pasted "https://Discord.com/..." would + # otherwise be rejected as not-a-Discord-URL. + String.downcase(host) in @discord_hosts and valid_webhook_path?(path) + + _ -> + false + end + end + + def valid_webhook_url?(_), do: false + + defp valid_webhook_path?(path) do + case String.split(path, "/", trim: true) do + ["api", "webhooks", id, token] -> + id != "" and token != "" + + ["api", version, "webhooks", id, token] -> + String.starts_with?(version, "v") and id != "" and token != "" + + _ -> + false + end + end + + defmodule ValidateWebhookUrl do + @moduledoc false + use Ash.Resource.Validation + + @impl true + def validate(changeset, _opts, _context) do + # AshCloak rewrites the encrypted field into a changeset *argument* (the + # stored attribute is `encrypted_webhook_url`, and `webhook_url` becomes a + # calculation). Reading only the attribute yields `%Ash.NotLoaded{}` — not + # nil — which fails every validity check and rejects even valid URLs. + # Read the argument first so the value being written is what gets checked. + case Ash.Changeset.get_argument_or_attribute(changeset, :webhook_url) do + nil -> + :ok + + url -> + if WandererApp.Api.MapDiscordWebhook.valid_webhook_url?(url) do + :ok + else + {:error, + field: :webhook_url, + message: + "must be a Discord webhook URL, e.g. https://discord.com/api/webhooks/{id}/{token}"} + end + end + end + end + + # after_transaction, not after_action: an after_action hook evicts the cached + # config while this row is still uncommitted, so a killmail arriving in that + # window reloads pre-commit state and re-caches it — the old URL on an update, + # or the negative `:none` marker if the parent was created in the same + # transaction. Either sticks for the cache's 5-minute TTL. On rollback the + # error result passes straight through: there is nothing to invalidate, and + # evicting anyway would only discard a still-correct entry. + @doc false + def invalidate_cache(_changeset, {:ok, record}, _context) do + do_invalidate(record) + {:ok, record} + end + + def invalidate_cache(_changeset, other, _context), do: other + + @doc false + def after_destroy(_changeset, {:ok, record}, _context) do + do_invalidate(record) + # Stop this destination's delivery worker too: without this, anything already + # queued keeps posting to a webhook the user has just removed. + WandererApp.ExternalEvents.Discord.WorkerSupervisor.stop_worker(record.id) + {:ok, record} + end + + def after_destroy(_changeset, other, _context), do: other + + defp do_invalidate(record) do + case Ash.get(WandererApp.Api.MapDiscordNotification, record.notification_id) do + {:ok, notification} -> + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(notification.map_id) + + error -> + # Swallowed rather than raised — a failed eviction must not fail the + # write that already committed — but logged, because the consequence is + # a routing cache that serves the previous destination for the rest of + # the TTL, which is otherwise indistinguishable from "the user's change + # did nothing". + Logger.warning( + "[MapDiscordWebhook] cache not invalidated for webhook #{record.id}: #{inspect(error)}" + ) + + :ok + end + rescue + exception -> + Logger.warning( + "[MapDiscordWebhook] cache invalidation raised for webhook #{record.id}: " <> + Exception.message(exception) + ) + + :ok + end +end diff --git a/priv/repo/migrations/20260801234058_add_map_discord_notifications.exs b/priv/repo/migrations/20260801234058_add_map_discord_notifications.exs new file mode 100644 index 000000000..8db609d03 --- /dev/null +++ b/priv/repo/migrations/20260801234058_add_map_discord_notifications.exs @@ -0,0 +1,55 @@ +defmodule WandererApp.Repo.Migrations.AddMapDiscordNotifications do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:map_discord_notifications_v1, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :enabled?, :boolean, null: false, default: true + add :wh_only, :boolean, null: false, default: true + add :excluded_systems, {:array, :bigint}, null: false, default: [] + add :last_delivery_at, :utc_datetime + add :last_error, :text + add :last_error_at, :utc_datetime + add :consecutive_failures, :bigint, null: false, default: 0 + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :map_id, + references(:maps_v1, + column: :id, + name: "map_discord_notifications_v1_map_id_fkey", + type: :uuid, + on_delete: :delete_all + ), + null: false + + add :encrypted_webhook_url, :binary, null: false + end + + create unique_index(:map_discord_notifications_v1, [:map_id], + name: "map_discord_notifications_v1_unique_map_id_index" + ) + end + + def down do + drop_if_exists unique_index(:map_discord_notifications_v1, [:map_id], + name: "map_discord_notifications_v1_unique_map_id_index" + ) + + drop constraint(:map_discord_notifications_v1, "map_discord_notifications_v1_map_id_fkey") + + drop table(:map_discord_notifications_v1) + end +end diff --git a/priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs b/priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs new file mode 100644 index 000000000..51c3ee30c --- /dev/null +++ b/priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs @@ -0,0 +1,54 @@ +defmodule WandererApp.Repo.Migrations.CreateMapDiscordWebhooks do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:map_discord_webhooks_v1, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :role, :text, null: false + add :enabled?, :boolean, null: false, default: true + add :last_delivery_at, :utc_datetime + add :last_error, :text + add :last_error_at, :utc_datetime + add :consecutive_failures, :bigint, null: false, default: 0 + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :notification_id, + references(:map_discord_notifications_v1, + column: :id, + name: "map_discord_webhooks_v1_notification_id_fkey", + type: :uuid, + on_delete: :delete_all + ), + null: false + + add :encrypted_webhook_url, :binary, null: false + end + + create unique_index(:map_discord_webhooks_v1, [:notification_id, :role], + name: "map_discord_webhooks_v1_unique_notification_role_index" + ) + end + + def down do + drop_if_exists unique_index(:map_discord_webhooks_v1, [:notification_id, :role], + name: "map_discord_webhooks_v1_unique_notification_role_index" + ) + + drop constraint(:map_discord_webhooks_v1, "map_discord_webhooks_v1_notification_id_fkey") + + drop table(:map_discord_webhooks_v1) + end +end diff --git a/priv/repo/migrations/20260803210357_split_discord_webhooks.exs b/priv/repo/migrations/20260803210357_split_discord_webhooks.exs new file mode 100644 index 000000000..f82ab2e52 --- /dev/null +++ b/priv/repo/migrations/20260803210357_split_discord_webhooks.exs @@ -0,0 +1,183 @@ +defmodule WandererApp.Repo.Migrations.SplitDiscordWebhooks do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + + Hand-edited: the generator's drop/add for `map_discord_notifications_v1` is + unchanged, but a data-copy step has been inserted so existing rows aren't + destroyed, and the four unrelated resources it also picked up (maps_v1, + map_chain_v1, map_system_structures_v1, map_system_comments_v1 — stale + `priv/resource_snapshots` drift, pre-existing and not part of this feature) + have been removed from both `up/0` and `down/0`. + """ + + use Ecto.Migration + + def up do + # Data step: give every existing notification a :system webhook carrying the + # URL and failure state it used to hold itself. + # + # The ciphertext is copied verbatim, no decrypt/re-encrypt round trip. + # AshCloak.do_encrypt/2 is + # value |> :erlang.term_to_binary() |> vault.encrypt!() |> Base.encode64() + # (deps/ash_cloak/lib/ash_cloak.ex:65-73). The resource is used only to pick + # a vault; neither the table nor the row identity enters the ciphertext, and + # the vault's AES-GCM uses fixed AAD. The bytes therefore decrypt correctly + # from the new table. Do not replace this with an application-level migration. + # + # `enabled?` is copied to BOTH rows. The old single flag conflated two + # meanings — the user switching notifications off, and record_failure + # auto-disabling after ten consecutive failures — and the migration cannot + # tell them apart retroactively. Copying it down is the conservative + # direction: a map that was silent before the upgrade stays silent after it, + # and no webhook starts posting because a migration guessed generously. The + # cost is that re-enabling a previously-disabled map also needs the + # destination re-enabled. Do NOT "simplify" this to enabled? = true. + # `"enabled?"` is double-quoted throughout: Postgres rejects `?` in an + # unquoted identifier (the column is declared as `enabled?` because Ash + # attribute names may end in `?`, but the underlying SQL identifier still + # needs quoting outside of Ecto/Ash-generated DDL). + # Guarded by `NOT EXISTS` against the child's real unique index + # (`map_discord_webhooks_v1_unique_notification_role_index` on + # `(notification_id, role)`): without it, any notification that already + # has a `:system` child — a partially-applied deploy, a hand-repaired row, + # or simply re-running this migration — aborts the whole statement on the + # first collision instead of skipping the rows already split. + # + # Wrapped in a DO block (rather than a bare `execute`) so a notification + # excluded by the guard can be *named* instead of silently losing its + # `encrypted_webhook_url` when the column is dropped below. `up/0` runs + # unattended during a deploy, so it must not abort here — skipping is the + # correct, safe behavior, exactly as the guard already does — but it must + # leave evidence in the deploy log. The skipped set is computed BEFORE the + # INSERT (capturing exactly the rows the guard is about to exclude) and + # reported via `RAISE WARNING` AFTER the INSERT has run, so what's printed + # reflects the final, settled state rather than a snapshot that a + # concurrent write could have invalidated. `RAISE WARNING` does not abort + # the enclosing transaction — only `RAISE EXCEPTION` does. + execute(""" + DO $$ + DECLARE + already_split_ids text; + already_split_count int; + BEGIN + SELECT string_agg(n.id::text, ', '), count(*) + INTO already_split_ids, already_split_count + FROM map_discord_notifications_v1 n + WHERE n.encrypted_webhook_url IS NOT NULL + AND EXISTS ( + SELECT 1 FROM map_discord_webhooks_v1 w + WHERE w.notification_id = n.id AND w.role = 'system' + ); + + INSERT INTO map_discord_webhooks_v1 ( + id, notification_id, role, encrypted_webhook_url, "enabled?", + last_delivery_at, last_error, last_error_at, consecutive_failures, + inserted_at, updated_at + ) + SELECT + gen_random_uuid(), n.id, 'system', n.encrypted_webhook_url, n."enabled?", + n.last_delivery_at, n.last_error, n.last_error_at, n.consecutive_failures, + (now() AT TIME ZONE 'utc'), (now() AT TIME ZONE 'utc') + FROM map_discord_notifications_v1 n + WHERE NOT EXISTS ( + SELECT 1 FROM map_discord_webhooks_v1 w + WHERE w.notification_id = n.id AND w.role = 'system' + ); + + IF already_split_count > 0 THEN + RAISE WARNING 'split_discord_webhooks: % notification(s) already had a :system webhook; their parent encrypted_webhook_url was NOT migrated and is about to be dropped (ids: %)', already_split_count, already_split_ids; + END IF; + END $$; + """) + + alter table(:map_discord_notifications_v1) do + remove :encrypted_webhook_url + remove :consecutive_failures + remove :last_error_at + remove :last_error + remove :last_delivery_at + add :focus_corp_ids, {:array, :bigint}, null: false, default: [] + end + end + + def down do + alter table(:map_discord_notifications_v1) do + # Dropping this DISCARDS every configured focus corporation: the column + # did not exist before this migration, so there is nowhere to roll it + # back to. A later re-run of up/0 recreates it with the default `[]`. + # Expected and accepted — focus corporations must be reconfigured after a + # rollback and re-apply. Unlike the webhook URL below, this is a + # preference, not a credential. + remove :focus_corp_ids + add :last_delivery_at, :utc_datetime + add :last_error, :text + add :last_error_at, :utc_datetime + add :consecutive_failures, :bigint, null: false, default: 0 + # 1. Restore the column WITHOUT the NOT NULL constraint. Codegen would + # have written `null: false` here (mirroring the resource's + # `allow_nil? false`); a NOT NULL column with no default added to a + # table that already has rows fails immediately, before the reverse + # copy below ever runs, aborting the rollback. + add :encrypted_webhook_url, :binary, null: true + end + + # 2. Copy each notification's :system destination back onto the parent. + execute(""" + UPDATE map_discord_notifications_v1 n + SET encrypted_webhook_url = w.encrypted_webhook_url, + "enabled?" = w."enabled?", + last_delivery_at = w.last_delivery_at, + last_error = w.last_error, + last_error_at = w.last_error_at, + consecutive_failures = w.consecutive_failures + FROM map_discord_webhooks_v1 w + WHERE w.notification_id = n.id AND w.role = 'system' + """) + + # 3. Delete the :system rows now that their data lives back on the parent. + # Without this, re-running `up/0` after a rollback (or rolling back + # twice) hits the child's unique (notification_id, role) identity: the + # old :system row is still there, so the INSERT in `up/0` collides with + # it. `:character` rows are untouched — they were never derived from the + # parent and have nowhere to roll back to. + # Scoped with a join to the parent table (rather than a bare + # `WHERE role = 'system'`) to match exactly the rows the UPDATE above + # just restored, not every `:system` row in the database. + execute(""" + DELETE FROM map_discord_webhooks_v1 w + USING map_discord_notifications_v1 n + WHERE w.notification_id = n.id AND w.role = 'system' + """) + + # 4. Only now can the original constraint be reinstated. A notification with + # no :system child would fail here — which is correct: it has no URL to + # roll back to, and silently leaving the column nullable would diverge + # from the pre-migration schema. Without the preflight below, that + # failure surfaces as Postgres' generic "column ... contains null + # values" error, which names neither the offending rows nor the fix. + # `down/0` is run by hand by an operator, so aborting here is correct — + # unlike `up/0` above, there is no unattended deploy to keep moving — + # but the abort must explain itself instead of leaving the operator to + # go spelunking. + execute(""" + DO $$ + DECLARE + unrestorable_ids text; + BEGIN + SELECT string_agg(id::text, ', ') INTO unrestorable_ids + FROM map_discord_notifications_v1 + WHERE encrypted_webhook_url IS NULL; + + IF unrestorable_ids IS NOT NULL THEN + RAISE EXCEPTION 'split_discord_webhooks rollback: notification(s) have no :system webhook to restore a webhook_url from (ids: %). Re-create a :system webhook for each, or delete these notification rows, then retry the rollback.', unrestorable_ids; + END IF; + END $$; + """) + + execute( + "ALTER TABLE map_discord_notifications_v1 ALTER COLUMN encrypted_webhook_url SET NOT NULL" + ) + end +end diff --git a/priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json new file mode 100644 index 000000000..2c680e4b4 --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json @@ -0,0 +1,200 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "wh_only", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "excluded_systems", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_notifications_v1_map_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "28FA790E823D2C78153C94A6A5FCDDF298B77296E71057B12E1C63EC18B79053", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_notifications_v1_unique_map_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + } + ], + "name": "unique_map_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_notifications_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json new file mode 100644 index 000000000..ea3f780fc --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json @@ -0,0 +1,155 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "wh_only", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "excluded_systems", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "focus_corp_ids", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_notifications_v1_map_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "960C91E0921D294FEFEAB2584970EFE683284EB9453B3C0F09458E32B524A216", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_notifications_v1_unique_map_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + } + ], + "name": "unique_map_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_notifications_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json new file mode 100644 index 000000000..04113d16a --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json @@ -0,0 +1,189 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_webhooks_v1_notification_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_discord_notifications_v1" + }, + "scale": null, + "size": null, + "source": "notification_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "883023968CE7DD7C03F86AAB3B3D614C91A1046CD6E1C15015C3FCEBD1A9C517", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_webhooks_v1_unique_notification_role_index", + "keys": [ + { + "type": "atom", + "value": "notification_id" + }, + { + "type": "atom", + "value": "role" + } + ], + "name": "unique_notification_role", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_webhooks_v1" +} \ No newline at end of file diff --git a/test/unit/api/map_discord_notification_test.exs b/test/unit/api/map_discord_notification_test.exs new file mode 100644 index 000000000..2d1d5bbf0 --- /dev/null +++ b/test/unit/api/map_discord_notification_test.exs @@ -0,0 +1,249 @@ +defmodule WandererApp.Api.MapDiscordNotificationTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.ExternalEvents.Discord.{Worker, WorkerSupervisor} + alias WandererAppWeb.Factory + + defp valid_url, do: "https://discord.com/api/webhooks/123456789/abcdefTOKEN" + + # The dispatcher's per-map config cache, seeded and read directly so these + # tests do not depend on the dispatcher GenServer running. + @cache :discord_notification_cache + + defp await_condition(fun, timeout \\ 2_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await_condition(fun, deadline) + end + + defp do_await_condition(fun, deadline) do + case fun.() do + {:ok, value} -> + value + + :retry -> + if System.monotonic_time(:millisecond) > deadline do + flunk("condition not met before deadline") + else + Process.sleep(25) + do_await_condition(fun, deadline) + end + end + end + + setup do + map = Factory.insert(:map, %{}) + %{map: map} + end + + test "creates with policy defaults", %{map: map} do + assert {:ok, rec} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert rec.enabled? == true + assert rec.wh_only == true + assert rec.excluded_systems == [] + assert rec.focus_corp_ids == [] + end + + test "no longer carries webhook_url or failure state", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + refute Map.has_key?(rec, :webhook_url) + refute Map.has_key?(rec, :consecutive_failures) + refute Map.has_key?(rec, :last_error) + refute Map.has_key?(rec, :last_error_at) + refute Map.has_key?(rec, :last_delivery_at) + end + + test "focus_corp_ids round-trips", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, rec} = + MapDiscordNotification.update(rec, %{focus_corp_ids: [98_000_001, 98_000_002]}) + + assert rec.focus_corp_ids == [98_000_001, 98_000_002] + + assert {:ok, reloaded} = MapDiscordNotification.by_map(map.id) + assert reloaded.focus_corp_ids == [98_000_001, 98_000_002] + end + + test "create makes the parent and its :system webhook in one transaction", %{map: map} do + assert {:ok, rec} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, [hook]} = MapDiscordWebhook.by_notification(rec.id) + assert hook.role == :system + assert hook.webhook_url == valid_url() + end + + test "a rejected webhook url leaves no parent row behind", %{map: map} do + # The invariant "a system webhook always exists" is transactional, not + # declarative — the unique identity gives at most one webhook per role, never + # at least one. If the child create fails the parent must roll back, or the + # map is left with a policy row and nowhere to deliver. + assert {:error, _} = + MapDiscordNotification.create(%{ + map_id: map.id, + webhook_url: "https://evil.example.com/api/webhooks/1/tok" + }) + + assert {:error, _} = MapDiscordNotification.by_map(map.id) + end + + test "by_map loads the webhooks relationship", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + assert {:ok, found} = MapDiscordNotification.by_map(map.id) + refute match?(%Ash.NotLoaded{}, found.webhooks) + assert Enum.map(found.webhooks, & &1.role) |> Enum.sort() == [:character, :system] + end + + test "enforces one notification per map", %{map: map} do + {:ok, _} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:error, _} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + end + + test "deleting the map cascades the notification and its webhooks away", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + {:ok, [hook]} = MapDiscordWebhook.by_notification(rec.id) + + Ash.destroy!(map) + + assert {:error, _} = MapDiscordNotification.by_id(rec.id) + assert {:error, _} = MapDiscordWebhook.by_id(hook.id) + end + + test "destroy invalidates the cache and stops each webhook's worker", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, [system_hook]} = MapDiscordWebhook.by_notification(rec.id) + + {:ok, character_hook} = + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + start_supervised!(WorkerSupervisor) + registry = WorkerSupervisor.registry() + + # Seed the routing cache so the destroy has something to evict — without + # this the test asserts only the worker teardown, and a regression in the + # destroy-side invalidation passes silently despite the test's name. + Cachex.put(@cache, map.id, rec) + + # Register one worker per webhook id — the key `stash_webhook_ids/2` reads + # and `after_destroy/3` stops by. Started directly against the real + # `Worker`/`Registry` (rather than through `WorkerSupervisor.deliver/2`) so + # this proves the actual registry entries this destroy path is responsible + # for clearing. + for webhook <- [system_hook, character_hook] do + start_supervised!( + {Worker, webhook_id: webhook.id, registry: registry, idle_timeout: :infinity}, + id: webhook.id, + restart: :temporary + ) + end + + assert [{_pid, _}] = Registry.lookup(registry, system_hook.id) + assert [{_pid, _}] = Registry.lookup(registry, character_hook.id) + + assert :ok = MapDiscordNotification.destroy(rec) + assert {:error, _} = MapDiscordNotification.by_map(map.id) + assert Cachex.get(@cache, map.id) == {:ok, nil} + + # Registry release on process exit is asynchronous, so poll rather than + # asserting immediately. + await_condition(fn -> + if Registry.lookup(registry, system_hook.id) == [] and + Registry.lookup(registry, character_hook.id) == [] do + {:ok, :done} + else + :retry + end + end) + end + + test "destroy tolerates the worker registry not running at all", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + # No WorkerSupervisor started in this test — the custom destroy must not + # crash just because the delivery infrastructure is down (e.g. webhooks + # globally disabled). + assert :ok = MapDiscordNotification.destroy(rec) + assert {:error, _} = MapDiscordNotification.by_map(map.id) + end + + # `after_action` hooks run inside the action's transaction, in the order they + # were added. This one is appended after the resource's own hooks, so it + # stands in for a killmail that arrives *just after* an `after_action` + # invalidation would have run: `DiscordDispatcher.load_and_cache/1` reads + # pre-commit state, finds no config, and stores the negative `:none` marker. + # + # Only an invalidation that runs after the transaction clears that marker. + # Under `after_action` it survives for the cache's 5-minute default TTL, and + # the map the user just configured posts nothing for five minutes. + defp cache_none_inside_transaction(changeset, map_id) do + Ash.Changeset.after_action(changeset, fn _changeset, record -> + Cachex.put(@cache, map_id, :none) + {:ok, record} + end) + end + + test "create invalidates the cache after the transaction, not inside it", %{map: map} do + assert {:ok, _rec} = + MapDiscordNotification + |> Ash.Changeset.for_create(:create, %{map_id: map.id, webhook_url: valid_url()}) + |> cache_none_inside_transaction(map.id) + |> Ash.create() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end + + test "update invalidates the cache after the transaction, not inside it", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, _rec} = + rec + |> Ash.Changeset.for_update(:update, %{wh_only: false}) + |> cache_none_inside_transaction(map.id) + |> Ash.update() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end + + test "a rolled-back create leaves the cached config alone", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + Cachex.put(@cache, map.id, rec) + + # A map with no notification of its own, so the create below can only fail + # on the child's URL validation. Reusing `map` would trip the one-per-map + # identity first and never exercise the rollback path this test is about. + fresh_map = Factory.insert(:map, %{}) + Cachex.put(@cache, fresh_map.id, rec) + + # Rejected by the child's URL validation, so the whole transaction rolls + # back. The after_transaction hook still runs, with an `{:error, _}` result: + # nothing was written, so nothing may be evicted. + assert {:error, _} = + MapDiscordNotification.create(%{ + map_id: fresh_map.id, + webhook_url: "https://evil.example.com/x" + }) + + assert {:ok, ^rec} = Cachex.get(@cache, fresh_map.id) + assert {:ok, ^rec} = Cachex.get(@cache, map.id) + end +end diff --git a/test/unit/api/map_discord_webhook_test.exs b/test/unit/api/map_discord_webhook_test.exs new file mode 100644 index 000000000..3dea96554 --- /dev/null +++ b/test/unit/api/map_discord_webhook_test.exs @@ -0,0 +1,462 @@ +defmodule WandererApp.Api.MapDiscordWebhookTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererAppWeb.Factory + + defp valid_url, do: "https://discord.com/api/webhooks/123456789/abcdefTOKEN" + + # The dispatcher's per-map config cache, seeded and read directly so these + # tests do not depend on the dispatcher GenServer running. + @cache :discord_notification_cache + + setup do + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + %{map: map, notification: notification} + end + + test "creates with valid discord url and defaults", %{notification: notification} do + assert {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + assert hook.role == :character + assert hook.webhook_url == valid_url() + assert hook.enabled? == true + assert hook.consecutive_failures == 0 + assert hook.last_error == nil + assert hook.last_error_at == nil + assert hook.last_delivery_at == nil + end + + test "accepts discordapp.com host", %{notification: notification} do + url = "https://discordapp.com/api/webhooks/123/tok" + + assert {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "accepts a versioned webhook path", %{notification: notification} do + url = "https://canary.discord.com/api/v10/webhooks/999/newtok" + + assert {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects non-https scheme", %{notification: notification} do + url = "http://discord.com/api/webhooks/123/tok" + + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects non-discord host", %{notification: notification} do + url = "https://evil.example.com/api/webhooks/123/tok" + + # Assert on the specific validation message, not a bare {:error, _}. A + # blanket-reject regression (e.g. reading the AshCloak attribute instead of + # the argument, which yields %Ash.NotLoaded{}) would satisfy {:error, _} + # while rejecting valid URLs too. + assert {:error, %Ash.Error.Invalid{errors: errors}} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + + assert Enum.any?(errors, fn e -> + Map.get(e, :field) == :webhook_url and + to_string(Map.get(e, :message, "")) =~ "Discord webhook URL" + end) + end + + test "rejects host that merely contains discord.com", %{notification: notification} do + url = "https://discord.com.evil.example/api/webhooks/123/tok" + + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects malformed webhook path", %{notification: notification} do + url = "https://discord.com/api/not-webhooks/123/tok" + + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects an unknown role", %{notification: notification} do + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :corporation, + webhook_url: valid_url() + }) + end + + test "enforces one webhook per (notification, role)", %{notification: notification} do + # `MapDiscordNotification.create/1` (setup) already creates the :system + # webhook — this asserts a second :system create for the same notification + # is rejected. + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :system, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + end + + test "allows both roles under the same notification", %{notification: notification} do + # `MapDiscordNotification.create/1` (setup) already created the :system + # webhook; only the :character one needs creating here. + assert {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + assert {:ok, hooks} = MapDiscordWebhook.by_notification(notification.id) + assert Enum.map(hooks, & &1.role) |> Enum.sort() == [:character, :system] + end + + test "rejects invalid url on UPDATE as well as create", %{notification: notification} do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + MapDiscordWebhook.update(hook, %{webhook_url: "https://evil.example.com/x"}) + + assert Enum.any?(errors, fn e -> + Map.get(e, :field) == :webhook_url and + to_string(Map.get(e, :message, "")) =~ "Discord webhook URL" + end) + + # The rejected value must not have been persisted. + {:ok, reloaded} = MapDiscordWebhook.by_id(hook.id) + assert reloaded.webhook_url == valid_url() + end + + test "accepts a valid replacement url on UPDATE", %{notification: notification} do + # Guards against a blanket-reject regression: replacement must still work. + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + replacement = "https://canary.discord.com/api/v10/webhooks/999/newtok" + + assert {:ok, updated} = MapDiscordWebhook.update(hook, %{webhook_url: replacement}) + assert updated.webhook_url == replacement + end + + test "set_enabled toggles only this webhook", %{notification: notification} do + # `MapDiscordNotification.create/1` (setup) already created the :system + # webhook. + {:ok, [sys]} = MapDiscordWebhook.by_notification(notification.id) + + {:ok, char} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + assert {:ok, char} = MapDiscordWebhook.set_enabled(char, %{enabled?: false}) + assert char.enabled? == false + + assert {:ok, sys} = MapDiscordWebhook.by_id(sys.id) + assert sys.enabled? == true + end + + test "destroying the notification cascades the webhooks away", %{notification: notification} do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + :ok = MapDiscordNotification.destroy(notification) + + assert {:error, _} = MapDiscordWebhook.by_id(hook.id) + end + + test "destroy tolerates a cache and worker registry that are not running", %{ + notification: notification + } do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + assert :ok = MapDiscordWebhook.destroy(hook) + assert {:error, _} = MapDiscordWebhook.by_id(hook.id) + end + + defp character_hook(notification) do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + hook + end + + test "record_failure increments and does not disable before the threshold", %{ + notification: notification + } do + hook = character_hook(notification) + + hook = + Enum.reduce(1..9, hook, fn _, acc -> + {:ok, updated} = MapDiscordWebhook.record_failure(acc, "boom") + updated + end) + + assert hook.consecutive_failures == 9 + assert hook.enabled? == true + assert hook.last_error == "boom" + assert hook.last_error_at != nil + end + + test "record_failure disables at 10 consecutive failures", %{notification: notification} do + hook = character_hook(notification) + + hook = + Enum.reduce(1..10, hook, fn _, acc -> + {:ok, updated} = MapDiscordWebhook.record_failure(acc, "boom") + updated + end) + + assert hook.consecutive_failures == 10 + assert hook.enabled? == false + end + + test "record_failure disables only the failing webhook", %{notification: notification} do + # This is the entire point of the split: before it, ten failures on the + # character channel would have silenced system kills too. + # + # `MapDiscordNotification.create/1` (setup) already created the :system + # webhook. + {:ok, [sys]} = MapDiscordWebhook.by_notification(notification.id) + + {:ok, char} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + Enum.reduce(1..10, char, fn _, acc -> + {:ok, updated} = MapDiscordWebhook.record_failure(acc, "boom") + updated + end) + + assert {:ok, sys} = MapDiscordWebhook.by_id(sys.id) + assert sys.enabled? == true + assert sys.consecutive_failures == 0 + end + + test "record_failure re-reads the counter rather than trusting a stale copy", %{ + notification: notification + } do + stale = character_hook(notification) + + # Advance the stored counter behind the back of the `stale` struct. + {:ok, _} = MapDiscordWebhook.record_failure(stale, "first") + + {:ok, updated} = MapDiscordWebhook.record_failure(stale, "second") + + assert updated.consecutive_failures == 2 + assert updated.last_error == "second" + end + + test "record_failure truncates an overlong error", %{notification: notification} do + hook = character_hook(notification) + + {:ok, hook} = MapDiscordWebhook.record_failure(hook, String.duplicate("x", 900)) + + assert String.length(hook.last_error) == 500 + end + + test "record_success clears the failure state", %{notification: notification} do + hook = character_hook(notification) + {:ok, hook} = MapDiscordWebhook.record_failure(hook, "boom") + + {:ok, hook} = MapDiscordWebhook.record_success(hook) + + assert hook.consecutive_failures == 0 + assert hook.last_error == nil + assert hook.last_error_at == nil + assert hook.last_delivery_at != nil + end + + test "disable switches the webhook off immediately", %{notification: notification} do + hook = character_hook(notification) + + {:ok, hook} = MapDiscordWebhook.disable(hook, "404 Not Found") + + assert hook.enabled? == false + assert hook.last_error == "404 Not Found" + assert hook.last_error_at != nil + end + + # `after_action` hooks run inside the action's transaction, in the order they + # were added. This one is appended after the resource's own hooks, so it + # stands in for a killmail that arrives *just after* an `after_action` + # invalidation would have run: `DiscordDispatcher.load_and_cache/1` reads + # pre-commit state and re-caches it — the pre-update URL, or the negative + # `:none` marker. Only an invalidation that runs after the transaction clears + # that; under `after_action` the stale entry survives the cache's 5-minute + # default TTL, so the destination the user just changed keeps posting nowhere + # (or to the old channel). + defp cache_none_inside_transaction(changeset, map_id) do + Ash.Changeset.after_action(changeset, fn _changeset, record -> + Cachex.put(@cache, map_id, :none) + {:ok, record} + end) + end + + test "create invalidates the cache after the transaction, not inside it", %{ + map: map, + notification: notification + } do + assert {:ok, _hook} = + MapDiscordWebhook + |> Ash.Changeset.for_create(:create, %{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + |> cache_none_inside_transaction(map.id) + |> Ash.create() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end + + test "every health-updating action invalidates the cache after the transaction", %{ + map: map, + notification: notification + } do + hook_id = character_hook(notification).id + + for {action, params} <- [ + {:update, %{webhook_url: "https://canary.discord.com/api/v10/webhooks/999/newtok"}}, + {:set_enabled, %{enabled?: false}}, + {:record_failure, %{error: "boom"}}, + {:disable, %{error: "404 Not Found"}} + ] do + {:ok, hook} = MapDiscordWebhook.by_id(hook_id) + + assert {:ok, _} = + hook + |> Ash.Changeset.for_update(action, params) + |> cache_none_inside_transaction(map.id) + |> Ash.update() + + assert Cachex.get(@cache, map.id) == {:ok, nil}, + "#{action} left the pre-commit cache entry in place" + end + end + + test "a rejected update leaves the cached config alone", %{ + map: map, + notification: notification + } do + hook = character_hook(notification) + Cachex.put(@cache, map.id, notification) + + assert {:error, _} = + MapDiscordWebhook.update(hook, %{webhook_url: "https://evil.example.com/x"}) + + # The after_transaction hook runs on failure too, with an `{:error, _}` + # result: nothing was written, so nothing may be evicted. + assert {:ok, ^notification} = Cachex.get(@cache, map.id) + end + + describe "valid_webhook_url?/1" do + test "accepts a Discord host regardless of case" do + # URI.parse/1 returns the host as typed, and users paste URLs, so a + # capitalised host must not be mistaken for a non-Discord one. + for host <- ~w(discord.com Discord.com DISCORD.COM ptb.Discord.com CANARY.discord.com) do + url = "https://#{host}/api/webhooks/123456789/abcdefTOKEN" + assert MapDiscordWebhook.valid_webhook_url?(url), "rejected #{url}" + end + end + + test "still rejects a lookalike host" do + refute MapDiscordWebhook.valid_webhook_url?( + "https://discord.com.evil.example/api/webhooks/1/t" + ) + + refute MapDiscordWebhook.valid_webhook_url?("https://Evil.example/api/webhooks/1/t") + end + end + + test "update cannot re-parent a webhook onto another notification", %{ + notification: notification + } do + hook = character_hook(notification) + + {:ok, other_notification} = other_map_with_notification() + + # `notification_id` is outside the update action's accept list: moving a + # webhook would carry the credential onto another map, and `do_invalidate/1` + # resolves the notification *after* the write, so the original map would + # keep routing to a destination it no longer owns for the rest of the TTL. + assert {:error, error} = + MapDiscordWebhook.update(hook, %{notification_id: other_notification.id}) + + assert Exception.message(error) =~ "notification_id" + + {:ok, reloaded} = MapDiscordWebhook.by_id(hook.id) + assert reloaded.notification_id == notification.id + end + + defp other_map_with_notification do + other_map = Factory.insert(:map, %{}) + + WandererApp.Api.MapDiscordNotification.create(%{ + map_id: other_map.id, + webhook_url: "https://discord.com/api/webhooks/999/othertok" + }) + end +end diff --git a/test/unit/repo/migrations/discord_webhook_split_test.exs b/test/unit/repo/migrations/discord_webhook_split_test.exs new file mode 100644 index 000000000..86f9a5f35 --- /dev/null +++ b/test/unit/repo/migrations/discord_webhook_split_test.exs @@ -0,0 +1,271 @@ +# Loaded explicitly: migration files under `priv/repo/migrations` are not part +# of `elixirc_paths` (see mix.exs), so the module is not compiled into the app +# by default. Requiring it here — the real file, not a hand-typed copy of its +# SQL — is what lets this test prove the code that will actually run in +# production, per the review that flagged the previous version of this file. +Code.require_file( + "priv/repo/migrations/20260803210357_split_discord_webhooks.exs", + File.cwd!() +) + +defmodule WandererApp.Repo.Migrations.DiscordWebhookSplitTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererAppWeb.Factory + + @version 20_260_803_210_357 + @migration WandererApp.Repo.Migrations.SplitDiscordWebhooks + + defp valid_url, do: "https://discord.com/api/webhooks/123456789/abcdefTOKEN" + defp character_url, do: "https://discord.com/api/webhooks/222/othertok" + + # Runs the migration module's real up/0 or down/0 directly in the calling + # process (rather than through `Ecto.Migrator.up/down`, which always wraps + # the call in `Task.async` — a process the sandbox's shared-mode connection + # is not reliably reachable from, causing connection-checkout timeouts). + # `Ecto.Migration.Runner.run/8` is the same primitive `Ecto.Migrator` uses + # internally to execute a migration once the transaction/task plumbing is + # stripped away, so this still runs the actual `up/0`/`down/0` bodies. + defp run_migration(direction) do + Ecto.Migration.Runner.run( + WandererApp.Repo, + WandererApp.Repo.config(), + @version, + @migration, + :forward, + direction, + direction, + log: false + ) + end + + defp fetch_parent_row(notification_id) do + {:ok, %{rows: [row], columns: columns}} = + WandererApp.Repo.query( + """ + SELECT encrypted_webhook_url, "enabled?", last_delivery_at, last_error, + last_error_at, consecutive_failures + FROM map_discord_notifications_v1 + WHERE id = $1 + """, + [Ecto.UUID.dump!(notification_id)] + ) + + columns |> Enum.zip(row) |> Map.new() + end + + defp count_system_rows(notification_id) do + {:ok, %{rows: [[count]]}} = + WandererApp.Repo.query( + "SELECT count(*) FROM map_discord_webhooks_v1 WHERE notification_id = $1 AND role = 'system'", + [Ecto.UUID.dump!(notification_id)] + ) + + count + end + + # Postgres delivers `RAISE WARNING`/`RAISE NOTICE` output as protocol notice + # messages attached to the `Postgrex.Result` of the query that produced them + # (deps/postgrex/lib/postgrex/protocol.ex: `msg_notice` accumulates into + # `result.messages`). Ecto surfaces that same result via the + # `[:wanderer_app, :repo, :query]` telemetry event's `result` metadata + # (deps/ecto_sql/lib/ecto/adapters/sql.ex `log/5`), regardless of whether the + # query ran through a plain `Repo.query!` or — as here — through + # `Ecto.Migration.Runner.run/8`. Attaching a telemetry handler is therefore + # the only way to observe the warning text without hand-typing the SQL + # ourselves: `execute/1` inside a migration discards its return value. + defp with_pg_notices(fun) do + handler_id = {__MODULE__, make_ref()} + test_pid = self() + + :telemetry.attach( + handler_id, + [:wanderer_app, :repo, :query], + fn _event, _measurements, %{result: result}, _config -> + case result do + {:ok, %{messages: [_ | _] = messages}} -> + send(test_pid, {:pg_notice_messages, messages}) + + _ -> + :ok + end + end, + nil + ) + + # try/after: a raise inside fun.() would otherwise leave the handler + # attached for the rest of the suite, sending {:pg_notice_messages, _} to a + # finished test process on every repo query. + return_value = + try do + fun.() + after + :telemetry.detach(handler_id) + end + + notices = + Stream.unfold(:start, fn _ -> + receive do + {:pg_notice_messages, messages} -> {messages, :cont} + after + 0 -> nil + end + end) + |> Enum.to_list() + |> List.flatten() + + {return_value, notices} + end + + test "down/0 copies a webhook's state onto the parent and deletes it; up/0 splits it back out, leaving :character rows untouched throughout" do + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, [system_hook]} = MapDiscordWebhook.by_notification(notification.id) + + {:ok, character_hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: character_url() + }) + + # Give the :system destination real failure state and a disabled flag, so + # the round trip has something non-default to prove is carried correctly. + {:ok, system_hook} = MapDiscordWebhook.record_failure(system_hook, "boom") + {:ok, system_hook} = MapDiscordWebhook.set_enabled(system_hook, %{enabled?: false}) + + # -- down/0, for real -------------------------------------------------- + + :ok = run_migration(:down) + + # The :system row's state moved onto the parent (raw SQL: down/0 drops the + # very column the resource depends on to read `focus_corp_ids`, so an + # Ash-level read of the parent is not valid again until up/0 restores it). + parent = fetch_parent_row(notification.id) + + assert parent["enabled?"] == false + assert parent["last_error"] == "boom" + assert parent["last_error_at"] != nil + assert parent["consecutive_failures"] == 1 + assert parent["encrypted_webhook_url"] != nil + + # The :system row itself is gone — down/0's DELETE ran for real. + assert count_system_rows(notification.id) == 0 + assert {:error, _} = MapDiscordWebhook.by_id(system_hook.id) + + # The :character row was never derived from the parent and down/0 must + # not have touched it. + assert {:ok, untouched} = MapDiscordWebhook.by_id(character_hook.id) + assert untouched.webhook_url == character_url() + assert untouched.enabled? == true + + # -- up/0, for real ------------------------------------------------------ + + :ok = run_migration(:up) + + assert {:ok, hooks} = MapDiscordWebhook.by_notification(notification.id) + assert Enum.map(hooks, & &1.role) |> Enum.sort() == [:character, :system] + + migrated_system = Enum.find(hooks, &(&1.role == :system)) + + # Ciphertext survived a real round trip through both migration directions + # and still decrypts — the assumption the whole SQL data step rests on. + assert migrated_system.webhook_url == valid_url() + assert migrated_system.consecutive_failures == 1 + assert migrated_system.last_error == "boom" + assert migrated_system.last_error_at != nil + assert migrated_system.enabled? == false + + # A fresh row (up/0 always mints a new id via gen_random_uuid()), not the + # original one down/0 deleted. + refute migrated_system.id == system_hook.id + + # The parent's own `focus_corp_ids` — restored by up/0's `add` — is back + # at its default; readable again because up/0 recreated the schema shape + # the resource expects. + assert {:ok, reloaded_notification} = MapDiscordNotification.by_map(map.id) + assert reloaded_notification.focus_corp_ids == [] + + # The :character row is exactly as it was before either migration ran. + still_untouched = Enum.find(hooks, &(&1.role == :character)) + assert still_untouched.id == character_hook.id + assert still_untouched.webhook_url == character_url() + + # -- up/0's idempotency guard -------------------------------------------- + # + # Roll back once more, then plant a :system row for this notification + # by hand — a hand-repaired row, or a partially-applied prior deploy — + # *before* up/0 runs. Without the `WHERE NOT EXISTS` guard, up/0's INSERT + # would collide with the child's unique (notification_id, role) identity + # and abort the whole migration. + :ok = run_migration(:down) + + pre_existing_id = Ecto.UUID.generate() + + {:ok, _} = + WandererApp.Repo.query( + """ + INSERT INTO map_discord_webhooks_v1 + (id, notification_id, role, encrypted_webhook_url, "enabled?", consecutive_failures, inserted_at, updated_at) + VALUES ($1, $2, 'system', $3, true, 0, now() AT TIME ZONE 'utc', now() AT TIME ZONE 'utc') + """, + [ + Ecto.UUID.dump!(pre_existing_id), + Ecto.UUID.dump!(notification.id), + <<0, 0, 0>> + ] + ) + + # up/0 must not abort just because it's skipping this row — and it must + # say so in a `RAISE WARNING` naming the notification, since the parent's + # `encrypted_webhook_url` (still non-null after the `:down` above restored + # it) is about to be dropped without ever reaching this hand-planted + # :system row. + {migration_result, notices} = with_pg_notices(fn -> run_migration(:up) end) + + assert migration_result == :ok + + warning = Enum.find(notices, &(&1.severity == "WARNING")) + assert warning, "expected a RAISE WARNING notice, got: #{inspect(notices)}" + assert warning.message =~ "split_discord_webhooks" + assert warning.message =~ to_string(notification.id) + + # No duplicate: still exactly one :system row for this notification. + assert count_system_rows(notification.id) == 1 + + # And it is the pre-existing row, untouched — proving the guard actually + # skipped the INSERT rather than, say, silently overwriting it. + {:ok, %{rows: [[stored_id]]}} = + WandererApp.Repo.query( + "SELECT id FROM map_discord_webhooks_v1 WHERE notification_id = $1 AND role = 'system'", + [Ecto.UUID.dump!(notification.id)] + ) + + assert Ecto.UUID.load!(stored_id) == pre_existing_id + end + + test "down/0 raises a legible error naming the notification when it has no :system webhook to restore from" do + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, [system_hook]} = MapDiscordWebhook.by_notification(notification.id) + + # Destroy the only :system webhook this notification has. down/0's + # reverse UPDATE joins on `role = 'system'`; with no such row to join to, + # the parent's `encrypted_webhook_url` is left NULL, and the preflight + # check must abort with a message naming this notification rather than + # letting `SET NOT NULL` fail with Postgres' generic, id-less error. + :ok = MapDiscordWebhook.destroy(system_hook) + + assert_raise Postgrex.Error, ~r/#{notification.id}/, fn -> + run_migration(:down) + end + end +end From 499a4c13b6dd5760b9778cb6d1b99b8757369e49 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sat, 8 Aug 2026 17:59:17 +0000 Subject: [PATCH 14/94] zoo(feat): add the discord notification delivery pipeline --- .../discord/embed_formatter.ex | 407 ++++++ .../external_events/discord/http_client.ex | 55 + .../external_events/discord/matcher.ex | 290 +++++ .../external_events/discord/router.ex | 79 ++ .../external_events/discord/system_name.ex | 82 ++ .../external_events/discord/worker.ex | 422 ++++++ .../discord/worker_supervisor.ex | 145 +++ .../external_events/discord_dispatcher.ex | 504 ++++++++ .../external_events/map_event_relay.ex | 4 + test/support/discord_http_stub.ex | 66 + .../discord/embed_formatter_test.exs | 590 +++++++++ .../discord/matcher_involvement_test.exs | 197 +++ .../external_events/discord/router_test.exs | 194 +++ .../discord/system_name_test.exs | 173 +++ .../external_events/discord/worker_test.exs | 483 +++++++ .../discord_dispatcher_test.exs | 1126 +++++++++++++++++ .../discord_killmail_age_test.exs | 147 +++ .../discord/http_stub_test.exs | 25 + .../external_events/discord/matcher_test.exs | 189 +++ 19 files changed, 5178 insertions(+) create mode 100644 lib/wanderer_app/external_events/discord/embed_formatter.ex create mode 100644 lib/wanderer_app/external_events/discord/http_client.ex create mode 100644 lib/wanderer_app/external_events/discord/matcher.ex create mode 100644 lib/wanderer_app/external_events/discord/router.ex create mode 100644 lib/wanderer_app/external_events/discord/system_name.ex create mode 100644 lib/wanderer_app/external_events/discord/worker.ex create mode 100644 lib/wanderer_app/external_events/discord/worker_supervisor.ex create mode 100644 lib/wanderer_app/external_events/discord_dispatcher.ex create mode 100644 test/support/discord_http_stub.ex create mode 100644 test/unit/external_events/discord/embed_formatter_test.exs create mode 100644 test/unit/external_events/discord/matcher_involvement_test.exs create mode 100644 test/unit/external_events/discord/router_test.exs create mode 100644 test/unit/external_events/discord/system_name_test.exs create mode 100644 test/unit/external_events/discord/worker_test.exs create mode 100644 test/unit/external_events/discord_dispatcher_test.exs create mode 100644 test/unit/external_events/discord_killmail_age_test.exs create mode 100644 test/wanderer_app/external_events/discord/http_stub_test.exs create mode 100644 test/wanderer_app/external_events/discord/matcher_test.exs diff --git a/lib/wanderer_app/external_events/discord/embed_formatter.ex b/lib/wanderer_app/external_events/discord/embed_formatter.ex new file mode 100644 index 000000000..fbdd13cba --- /dev/null +++ b/lib/wanderer_app/external_events/discord/embed_formatter.ex @@ -0,0 +1,407 @@ +defmodule WandererApp.ExternalEvents.Discord.EmbedFormatter do + @moduledoc """ + Turns flattened killmails into Discord message bodies. + + Only `killmail_id`, `kill_time` and `solar_system_id` are guaranteed present + on a killmail (see `WandererApp.Kills.MessageHandler`), so every other field + is rendered defensively. + + Each kill arrives paired with the involvement verdict from + `WandererApp.ExternalEvents.Discord.Matcher.involvement/3`. The verdict, not + the payload, decides the colour and the author line. + """ + + @type verdict :: {:involved, :victim} | {:involved, :attacker} | :not_involved + + @max_embeds_per_message 10 + @max_kills_per_event 30 + + # Discord's documented embed limits. Exceeding any of them is a 400, not a + # truncation, and a 400 counts as a delivery failure — so ten kills in a + # system whose map-local name is long enough would trip + # `@max_consecutive_failures` and auto-disable the destination. `custom_name` + # and `temporary_name` carry no length constraint on `MapSystem`, so the + # title bound is reachable from ordinary user input, not just malice. + @max_title_length 256 + @max_description_length 4096 + # The per-message ceiling counts the text of every embed in the message + # together, so it can be breached by a batch that satisfies each field bound + # individually. + @max_message_text 6000 + + @color_loss 0xE74C3C + @color_kill 0x2ECC71 + + # ISK tiers for kills involving nobody we track, largest first. + # + # NOTE: @color_kill (0x2ECC71) and the 10M tier (0x00FF00) are both green. + # They are *distinct meanings* that happen to share a hue — "you killed + # something" versus "a bystander kill worth 10M-100M" — and they are + # disambiguated by the author line, which is present on a kill and absent on + # an uninvolved embed. Do not collapse these two constants into one. + @value_colors [ + {5_000_000_000, 0xFF0000}, + {1_000_000_000, 0xFF6600}, + {100_000_000, 0xFFFF00}, + {10_000_000, 0x00FF00} + ] + @color_default 0x808080 + + @zkill_base "https://zkillboard.com" + @image_base "https://images.evetech.net" + @thumbnail_size 1024 + + # ISK magnitude table, largest first: {threshold, divisor, unit, next_unit}. + # `next_unit` is what a value promotes to when rounding pushes it to >= 1000 + # within its own unit. It is nil at the top so T clamps instead of promoting, + # which makes self-promotion impossible by construction. + @isk_units [ + {1_000_000_000_000, 1_000_000_000_000, "T", nil}, + {1_000_000_000, 1_000_000_000, "B", "T"}, + {1_000_000, 1_000_000, "M", "B"}, + {1_000, 1_000, "K", "M"} + ] + + @doc """ + The per-event kill cap. Exposed so callers can tell which kills were actually + formatted — the dispatcher must not mark kills past this cap as attempted, + since they are never rendered into a message. + """ + @spec max_kills_per_event() :: pos_integer() + def max_kills_per_event, do: @max_kills_per_event + + @spec format_batch([{map(), verdict()}], String.t() | nil) :: [map()] + def format_batch([], _system_name), do: [] + + def format_batch(entries, system_name) do + total = length(entries) + shown = Enum.take(entries, @max_kills_per_event) + overflow = total - length(shown) + + messages = + shown + |> Enum.map(fn {kill, verdict} -> format_kill(kill, verdict, system_name) end) + |> chunk_messages() + |> Enum.map(&%{"embeds" => &1}) + + append_overflow(messages, overflow) + end + + # Two bounds at once: at most @max_embeds_per_message embeds, and at most + # @max_message_text characters of embed text across them. Each embed is + # already within the per-field limits by construction, so a single embed can + # never exceed the message total on its own and this always terminates. + defp chunk_messages(embeds) do + embeds + |> Enum.reduce([], fn embed, acc -> + size = embed_text_length(embed) + + case acc do + [{chunk, chunk_size} | rest] + when length(chunk) < @max_embeds_per_message and chunk_size + size <= @max_message_text -> + [{[embed | chunk], chunk_size + size} | rest] + + _ -> + [{[embed], size} | acc] + end + end) + |> Enum.reverse() + |> Enum.map(fn {chunk, _size} -> Enum.reverse(chunk) end) + end + + # Discord counts title, description, field names and values, footer text and + # author name toward the per-message total. URLs and colours do not count. + defp embed_text_length(embed) do + fields = + embed + |> Map.get("fields", []) + |> Enum.map(&(String.length(&1["name"] || "") + String.length(&1["value"] || ""))) + |> Enum.sum() + + String.length(embed["title"] || "") + + String.length(embed["description"] || "") + + String.length(get_in(embed, ["footer", "text"]) || "") + + String.length(get_in(embed, ["author", "name"]) || "") + + fields + end + + defp append_overflow(messages, overflow) when overflow <= 0, do: messages + + defp append_overflow(messages, overflow) do + {init, [last]} = Enum.split(messages, -1) + init ++ [Map.put(last, "content", "…and #{overflow} more kills not shown.")] + end + + @spec format_kill(map(), verdict(), String.t() | nil) :: map() + def format_kill(kill, verdict, system_name) do + %{ + "title" => truncate(title(kill, system_name), @max_title_length), + "url" => zkill_url(kill["killmail_id"]), + "color" => color(verdict, kill["total_value"]), + "description" => truncate(description(kill), @max_description_length), + "fields" => fields(kill) + } + |> maybe_put("author", author(kill, verdict)) + |> maybe_put("thumbnail", thumbnail(kill)) + |> maybe_put("footer", footer(kill)) + |> drop_nils() + end + + # Ellipsis rather than a hard cut, so a clipped name reads as clipped instead + # of as a differently-named system. Measured in graphemes, matching how + # Discord counts: a name of emoji or non-Latin script would otherwise pass a + # byte-based check and still be rejected. + defp truncate(nil, _limit), do: nil + + defp truncate(text, limit) when is_binary(text) do + if String.length(text) <= limit, + do: text, + else: String.slice(text, 0, limit - 1) <> "…" + end + + defp title(kill, system_name) do + ship = present(kill["victim_ship_name"]) || "Unknown ship" + system = present(system_name) || "Unknown system" + "#{ship} destroyed in #{system}" + end + + defp color({:involved, :victim}, _value), do: @color_loss + defp color({:involved, :attacker}, _value), do: @color_kill + + defp color(:not_involved, value) when is_number(value) do + Enum.find_value(@value_colors, @color_default, fn {threshold, color} -> + if value >= threshold, do: color + end) + end + + defp color(:not_involved, _value), do: @color_default + + # Omitted entirely when we are not involved: neither "Kill" nor "Loss" would + # be a true statement about a fight none of our pilots were in. + defp author(_kill, :not_involved), do: nil + defp author(kill, {:involved, :victim}), do: author_line("Loss", kill["victim_corp_id"]) + defp author(kill, {:involved, :attacker}), do: author_line("Kill", kill["final_blow_corp_id"]) + + defp author_line(label, corp_id) when is_integer(corp_id) or is_binary(corp_id) do + %{ + "name" => label, + "icon_url" => "#{@image_base}/corporations/#{corp_id}/logo?size=64" + } + end + + defp author_line(label, _corp_id), do: %{"name" => label} + + # Prose, not a field grid. Each clause carries its own leading separator and + # returns nil when the underlying data is absent, so an NPC kill simply reads + # "X lost their Y." rather than naming a placeholder attacker. + defp description(kill) do + [ + victim_clause(kill), + final_blow_clause(kill), + top_damage_clause(kill), + others_clause(kill) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join() + |> Kernel.<>(".") + end + + defp victim_clause(kill) do + pilot = + character_link(kill["victim_char_id"], present(kill["victim_char_name"]) || "Unknown pilot") + + ship = present(kill["victim_ship_name"]) || "Unknown ship" + + case corporation_link(kill["victim_corp_id"], present(kill["victim_corp_ticker"])) do + nil -> "#{pilot} lost their **#{ship}**" + corp -> "#{pilot} (#{corp}) lost their **#{ship}**" + end + end + + defp final_blow_clause(kill) do + case present(kill["final_blow_char_name"]) do + nil -> + nil + + name -> + pilot = character_link(kill["final_blow_char_id"], name) + + case corporation_link(kill["final_blow_corp_id"], present(kill["final_blow_corp_ticker"])) do + nil -> " to #{pilot}" + corp -> " to #{pilot} (#{corp})" + end + end + end + + defp top_damage_clause(kill) do + with name when not is_nil(name) <- present(kill["top_damage_char_name"]), + true <- distinct_from_final_blow?(kill) do + ", top damage by #{character_link(kill["top_damage_char_id"], name)}" + else + _ -> nil + end + end + + # Ids are authoritative when both are present; names are the fallback for + # payloads that carry one without the other. Compared as strings because the + # two ids do not have to arrive as the same type: `collect_ids/2` normalizes + # to integers on the nested branch only, so a flat payload can pair an + # integer with a binary. Matching only `is_integer/1` on both would drop such + # a pair to the name comparison, which is exactly the case the ids exist to + # settle. + defp distinct_from_final_blow?(kill) do + case {kill["final_blow_char_id"], kill["top_damage_char_id"]} do + {fb, td} when (is_integer(fb) or is_binary(fb)) and (is_integer(td) or is_binary(td)) -> + to_string(fb) != to_string(td) + + _ -> + present(kill["final_blow_char_name"]) != present(kill["top_damage_char_name"]) + end + end + + # Relative to whichever pilot(s) got named in the clauses above — final blow, + # top damage, or both. With nobody named at all there is no antecedent for + # "others" to modify, so a wholly anonymous fight (e.g. an NPC final blow + # with no top-damage pilot either) renders no others-clause at all rather + # than a dangling ", and 1 other." An NPC final blow with a *named* + # top-damage pilot still gets an others-clause, since something was named. + defp others_clause(kill) do + named = named_attacker_count(kill) + + if named > 0 do + case kill["attacker_count"] do + count when is_integer(count) and count - named == 1 -> ", and 1 other" + count when is_integer(count) and count - named > 1 -> ", and #{count - named} others" + _ -> nil + end + end + end + + defp named_attacker_count(kill) do + final_blow = if present(kill["final_blow_char_name"]), do: 1, else: 0 + + top_damage = + if present(kill["top_damage_char_name"]) && distinct_from_final_blow?(kill), do: 1, else: 0 + + final_blow + top_damage + end + + defp character_link(id, name) when is_integer(id) or is_binary(id), + do: "**[#{name}](#{@zkill_base}/character/#{id}/)**" + + defp character_link(_id, name), do: "**#{name}**" + + defp corporation_link(_id, nil), do: nil + + defp corporation_link(id, ticker) when is_integer(id) or is_binary(id), + do: "**[#{ticker}](#{@zkill_base}/corporation/#{id}/)**" + + defp corporation_link(_id, ticker), do: "**#{ticker}**" + + defp fields(kill) do + [ + field("Value", format_isk(kill["total_value"]), true), + field("When", relative_time(kill["kill_time"]), true) + ] + |> Enum.reject(&is_nil/1) + end + + defp field(_name, nil, _inline), do: nil + defp field(name, value, inline), do: %{"name" => name, "value" => value, "inline" => inline} + + # `` renders client-side as "3 hours ago", in the reader's own + # timezone. An unparseable kill_time drops the field rather than guessing. + defp relative_time(kill_time) when is_binary(kill_time) do + case DateTime.from_iso8601(kill_time) do + {:ok, datetime, _offset} -> "" + _ -> nil + end + end + + defp relative_time(%DateTime{} = datetime), do: "" + + defp relative_time(%NaiveDateTime{} = naive), + do: relative_time(DateTime.from_naive!(naive, "Etc/UTC")) + + defp relative_time(unix) when is_integer(unix), do: "" + defp relative_time(_), do: nil + + # Selection is on FIELD PRESENCE ONLY. This is *not* a 404 fallback: Discord + # fetches the image itself when it renders the embed, so a failed fetch is + # never observable from here and cannot be reacted to. If the ship type id is + # present we use the ship render even if that render happens not to exist + # upstream; the character portrait is only for kills that carry no ship type. + defp thumbnail(kill) do + cond do + is_integer(kill["victim_ship_type_id"]) or is_binary(kill["victim_ship_type_id"]) -> + %{ + "url" => + "#{@image_base}/types/#{kill["victim_ship_type_id"]}/render?size=#{@thumbnail_size}" + } + + is_integer(kill["victim_char_id"]) or is_binary(kill["victim_char_id"]) -> + %{ + "url" => + "#{@image_base}/characters/#{kill["victim_char_id"]}/portrait?size=#{@thumbnail_size}" + } + + true -> + nil + end + end + + defp footer(kill) do + case kill["killmail_id"] do + nil -> nil + id -> %{"text" => "Killmail ID: #{id}"} + end + end + + defp zkill_url(nil), do: nil + defp zkill_url(id), do: "#{@zkill_base}/kill/#{id}/" + + @doc false + def format_isk(nil), do: nil + def format_isk(0), do: "0 ISK" + + def format_isk(value) when is_number(value) do + Enum.find_value(@isk_units, "#{round(value)} ISK", &format_at_unit(value, &1)) + end + + def format_isk(_), do: nil + + defp format_at_unit(value, {threshold, _divisor, _unit, _next}) when value < threshold, do: nil + + defp format_at_unit(value, {_threshold, divisor, unit, next_unit}) do + case {round_to(value / divisor), next_unit} do + # Top of the table: clamp rather than promote. + {rounded, nil} -> + "#{format_float(rounded)}#{unit} ISK" + + {rounded, next} when rounded >= 1000.0 -> + "#{format_float(round_to(rounded / 1000))}#{next} ISK" + + {rounded, _} -> + "#{format_float(rounded)}#{unit} ISK" + end + end + + # Format a float to avoid scientific notation (e.g., 1.0e3 -> "1000.0") + defp format_float(float) when is_float(float) do + # `:decimals` avoids scientific notation, keeping 1 decimal place + :erlang.float_to_list(float, [{:decimals, 1}]) + |> List.to_string() + end + + defp round_to(float), do: Float.round(float, 1) + + defp present(nil), do: nil + defp present(""), do: nil + defp present(value) when is_binary(value), do: value + defp present(value), do: to_string(value) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp drop_nils(map), do: Map.reject(map, fn {_k, v} -> is_nil(v) end) +end diff --git a/lib/wanderer_app/external_events/discord/http_client.ex b/lib/wanderer_app/external_events/discord/http_client.ex new file mode 100644 index 000000000..c8d45e89c --- /dev/null +++ b/lib/wanderer_app/external_events/discord/http_client.ex @@ -0,0 +1,55 @@ +defmodule WandererApp.ExternalEvents.Discord.HttpClient do + @moduledoc """ + Seam over HTTP delivery to Discord, so dispatch logic can be tested without + a live endpoint. The real implementation uses an isolated Finch pool. + """ + + @callback post(url :: String.t(), body :: map()) :: + {:ok, status :: integer(), headers :: list()} | {:error, term()} + + @doc "Returns the configured implementation module." + def impl do + Application.get_env( + :wanderer_app, + :discord_http_client, + WandererApp.ExternalEvents.Discord.HttpClient.Live + ) + end + + @doc "Posts a Discord message body, delegating to the configured implementation." + def post(url, body), do: impl().post(url, body) + + defmodule Live do + @moduledoc """ + Real HTTP delivery via the isolated Discord Finch pool. + + Named `Live` rather than `Finch` so the nested module does not shadow the + Finch library inside its own body. + """ + @behaviour WandererApp.ExternalEvents.Discord.HttpClient + + @timeout 15_000 + + @impl true + def post(url, body) do + headers = [{"content-type", "application/json"}] + + case Jason.encode(body) do + {:ok, json} -> + :post + |> Finch.build(url, headers, json) + |> Finch.request(WandererApp.Finch.Discord, receive_timeout: @timeout) + |> case do + {:ok, %Finch.Response{status: status, headers: resp_headers}} -> + {:ok, status, resp_headers} + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + {:error, {:encode_failed, reason}} + end + end + end +end diff --git a/lib/wanderer_app/external_events/discord/matcher.ex b/lib/wanderer_app/external_events/discord/matcher.ex new file mode 100644 index 000000000..923833c0b --- /dev/null +++ b/lib/wanderer_app/external_events/discord/matcher.ex @@ -0,0 +1,290 @@ +defmodule WandererApp.ExternalEvents.Discord.Matcher do + @moduledoc """ + Decides whether a killmail involves a map's tracked pilots. + + The tracked-pilot set is cached per map because `WandererApp.Map.list_characters/1` + hydrates every character on every call, which is far too expensive to run once + per killmail. + """ + + require Logger + + @cache :discord_notification_cache + # A backstop only: correctness comes from `invalidate_tracked/1`, fired by + # every writer of `map.characters`. The TTL bounds the damage of a missed + # invalidation to five minutes rather than the lifetime of the node. + @ttl :timer.minutes(5) + + @doc """ + The EVE character ids tracked on `map_id`, as **integers**. + + Membership rule: every character registered on the map, whether or not + their location tracker is running. + + `WandererApp.Api.Character`'s `eve_id` is a string; killmail payloads carry + integers. The conversion happens here, once per cache build, so that no + comparison site anywhere else has to think about it. + + Returns an empty `MapSet` if the map cannot be read (e.g. its server is not + running). Callers must treat that as "no tracked pilots" and fall back to + their conservative destination — this function never raises. + """ + @spec tracked_eve_ids(String.t()) :: MapSet.t(integer()) + def tracked_eve_ids(map_id) do + case Cachex.get(@cache, cache_key(map_id)) do + {:ok, %MapSet{} = ids} -> + ids + + _ -> + build_and_cache(map_id) + end + rescue + # `Cachex.get/2` and the `Cachex.put/4` in `build_and_cache/1` RAISE against + # an unstarted cache rather than returning an error tuple — the same Cachex + # contract `invalidate_tracked/1` below rescues. This is the read side, and + # its caller is `DiscordDispatcher.partition/3`: letting the raise through + # would lose the entire killmail batch, not just this map's tracked set. + # The documented "never raises" contract above is what makes the + # conservative empty-MapSet fallback safe for every caller. + error -> + Logger.warning( + "[Discord.Matcher] tracked-set cache unavailable for map #{map_id}: #{inspect(error)}" + ) + + MapSet.new() + end + + @doc """ + Drops the cached set for `map_id`. Must be called by every writer of + `map.characters`. + """ + @spec invalidate_tracked(String.t()) :: :ok + def invalidate_tracked(map_id) do + # Bumping the version inside the same transaction as the delete is what + # makes the delete stick. Without it, a build already in flight would + # `Cachex.put/4` its pre-delete set back afterwards and the stale entry + # would survive the full TTL — the exact failure the invalidation exists to + # prevent. The build re-reads the version under this same lock and discards + # itself if it changed. + Cachex.transaction(@cache, [cache_key(map_id), version_key(map_id)], fn worker -> + Cachex.incr(worker, version_key(map_id), 1) + Cachex.del(worker, cache_key(map_id)) + end) + + :ok + rescue + # Same contract as `DiscordDispatcher.invalidate_cache/1`, which drops the + # same cache: Cachex RAISES against an unstarted cache rather than + # returning an error tuple. Both are called from core map writes + # (`WandererApp.Map`'s three writers of `characters:`), so a context without + # the cache must not turn adding a character into a crash. + _ -> :ok + end + + @doc false + # Public only as a test seam, and only for the `build_fun` argument. + # + # The compare-and-set below is the whole point of `version_key/1`, and it can + # only be exercised by an `invalidate_tracked/1` that lands *after* the + # version read and *before* the write. A real `build/1` completes in + # microseconds and holds no lock a test could queue behind, so there is no + # way to hit that window from the outside; injecting the build makes the + # interleaving exact instead of hoping for it. Production always calls + # `build_and_cache/1`. + def build_and_cache(map_id, build_fun \\ &build/1) do + # Read the version BEFORE building. `build/1` is slow (it hydrates every + # character on the map), so it deliberately runs outside the lock; the + # version read here plus the re-check in `cache_put/3` is what turns that + # into a compare-and-set rather than a blind write. + version = read_version(map_id) + + case build_fun.(map_id) do + {:ok, ids} -> + cache_put(map_id, ids, version) + ids + + :error -> + # Deliberately NOT cached: a transient failure must not be pinned for + # the TTL, or every kill on this map is misrouted for five minutes. + MapSet.new() + end + end + + defp read_version(map_id) do + case Cachex.get(@cache, version_key(map_id)) do + {:ok, version} when is_integer(version) -> version + _ -> 0 + end + end + + # Rescued separately from `tracked_eve_ids/1` rather than under its rescue: + # the set has already been built at this point, so a cache that cannot store + # it must still not cost us the answer. Failing to cache is a performance + # problem; returning an empty set would be a routing error. + # + # The write is conditional on the version being unchanged since the build + # started. An `invalidate_tracked/1` that landed mid-build has already bumped + # it, so this build's set is known-stale and is dropped rather than written. + # The caller still returns it for THIS killmail — it was current when the + # build began — but the next killmail rebuilds instead of reading it back. + defp cache_put(map_id, ids, version) do + Cachex.transaction(@cache, [cache_key(map_id), version_key(map_id)], fn worker -> + if read_version_with(worker, map_id) == version do + Cachex.put(worker, cache_key(map_id), ids, ttl: @ttl) + end + end) + + :ok + rescue + _ -> :ok + end + + defp read_version_with(worker, map_id) do + case Cachex.get(worker, version_key(map_id)) do + {:ok, version} when is_integer(version) -> version + _ -> 0 + end + end + + defp build(map_id) do + # `WandererApp.Map.list_characters/1` calls `get_map!/1`, which does not + # raise when the map is absent from `:map_cache` — it logs and returns + # `%{}`, which `list_characters/1` would silently read as "zero + # characters" and we would (wrongly) cache as a valid empty set. Check + # the map's presence explicitly via the non-raising `get_map/1` so a map + # that isn't running is treated as a failed lookup, not a real empty map. + case WandererApp.Map.get_map(map_id) do + {:ok, _map} -> + ids = + map_id + |> WandererApp.Map.list_characters() + # `list_characters/1` can return `nil` entries for character ids on + # the map whose backing record no longer resolves + # (`Character.get_map_character!/2` logs and returns `nil` rather + # than raising). One stale id must cost one pilot, not the whole + # map's set. + |> Enum.reject(&is_nil/1) + |> Enum.map(& &1.eve_id) + |> Enum.map(&parse_eve_id/1) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + {:ok, ids} + + {:error, _reason} -> + # :debug, not :warning. The failure is deliberately not cached (see + # `build_and_cache/1`), so this line runs once per killmail — a busy map + # that is briefly absent from `:map_cache` would flood the log at + # warning level and bury real problems. The routing consequence is + # already conservative and visible in the notifications themselves. + Logger.debug(fn -> + "[Discord.Matcher] Map #{map_id} is not running; no tracked pilots" + end) + + :error + end + rescue + error -> + Logger.warning( + "[Discord.Matcher] Failed to build tracked set for map #{map_id}: #{inspect(error)}" + ) + + :error + end + + defp parse_eve_id(eve_id) when is_integer(eve_id), do: eve_id + + defp parse_eve_id(eve_id) when is_binary(eve_id) do + case Integer.parse(eve_id) do + {id, ""} -> + id + + _ -> + Logger.warning("[Discord.Matcher] Non-numeric eve_id skipped: #{inspect(eve_id)}") + nil + end + end + + defp parse_eve_id(_), do: nil + + defp cache_key(map_id), do: "map:#{map_id}:tracked_eve_ids" + + # Intentionally never expires. It is a monotonic counter, not data: if it + # aged out while a build held an older value, that build's stale set would + # compare equal to the reset counter and be written back. + defp version_key(map_id), do: "map:#{map_id}:tracked_eve_ids:version" + + @type verdict :: {:involved, :victim} | {:involved, :attacker} | :not_involved + + @doc """ + Decides whether a killmail involves this map's own pilots. + + Order is load-bearing (spec section 3): victim checks precede attacker checks, + so a kill where both sides are tracked renders as a *loss*. Losses are the + more urgent signal. + + `focus_corp_ids` widens "tracked" rather than acting as a separate routing + concept, so corporation focus earns the same colouring and the same routing + carve-outs as character tracking. + """ + @spec involvement(map(), MapSet.t(integer()), [integer()]) :: verdict() + def involvement(kill, tracked_eve_ids, focus_corp_ids) when is_list(focus_corp_ids) do + cond do + MapSet.member?(tracked_eve_ids, parse_eve_id(kill["victim_char_id"])) -> + {:involved, :victim} + + parse_eve_id(kill["victim_corp_id"]) in focus_corp_ids -> + {:involved, :victim} + + attacker_match?(kill, tracked_eve_ids, focus_corp_ids) -> + {:involved, :attacker} + + true -> + :not_involved + end + end + + # ABSENT is not EMPTY. Nested-format payloads always carry the attacker keys + # (possibly as empty lists); flat-format payloads omit them entirely. Treating + # a missing key as `[]` would assert "there were no tracked attackers", which + # we do not know. This is a compatibility behaviour for a payload shape we + # cannot enrich, not a normalization: admitting the data is unknown is better + # than pretending it is empty. + # + # Victim matching still runs normally either way — `victim_char_id` and + # `victim_corp_id` exist in both shapes. When the victim does not match and + # the attacker data is unknown, the verdict is `:not_involved`, which routes + # to the system webhook: the same conservative destination a matching-cache + # failure produces. + # + # `attacker_char_ids` / `attacker_corp_ids` are normalized to integers by + # `collect_ids/2` (message_handler.ex) at flatten time — but only on the + # *nested* branch (reached via `add_attacker_identity_data/2`). The flat + # branch returns the payload unmodified and applies no key whitelist and no + # coercion, so if a flat payload ever does carry these keys as binaries + # (nothing today enforces that it can't), `parse_eve_id/1` is the backstop. + # It passes integers straight through, so this costs nothing on the + # already-normalized nested path. + defp attacker_match?(kill, tracked_eve_ids, focus_corp_ids) do + if Map.has_key?(kill, "attacker_char_ids") or Map.has_key?(kill, "attacker_corp_ids") do + Enum.any?( + kill["attacker_char_ids"] || [], + &MapSet.member?(tracked_eve_ids, parse_eve_id(&1)) + ) or + Enum.any?(kill["attacker_corp_ids"] || [], &(parse_eve_id(&1) in focus_corp_ids)) + else + log_attacker_divergence(kill) + false + end + end + + # Logged once per occurrence, at debug, with the killmail id. If flat-format + # payloads turn out to be common in production this is visible in the logs + # rather than inferred from notifications that never arrived. + defp log_attacker_divergence(kill) do + Logger.debug(fn -> + "[Discord] killmail #{kill["killmail_id"]}: attacker data absent from payload; " <> + "involvement decided on the victim alone" + end) + end +end diff --git a/lib/wanderer_app/external_events/discord/router.ex b/lib/wanderer_app/external_events/discord/router.ex new file mode 100644 index 000000000..9b15ff8b9 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/router.ex @@ -0,0 +1,79 @@ +defmodule WandererApp.ExternalEvents.Discord.Router do + @moduledoc """ + Chooses the destination webhook for a single killmail. + + Rules, evaluated in order (design section 4): + + | # | Condition | Destination | + |---|----------------------------------------------------|-------------------| + | 1 | System in `excluded_systems`, **not** involved | drop | + | 2 | `wh_only` on, system not a wormhole, not involved | drop | + | 3 | Involved | character webhook | + | 4 | Otherwise | system webhook | + + Rules 1 and 2 are carve-outs: a kill involving your own pilots is always + interesting, wherever it happened, so the exclusion and wormhole-only filters + do not apply to it. + + ## Fallback + + When no `:character` webhook row exists, rule 3 resolves to the **system** + webhook. Every existing single-webhook configuration therefore keeps working + with no user action, and the character channel is purely opt-in. + + ## Disabled destinations drop; they do not reroute + + If the webhook a kill routes to is itself disabled — by the user or by the + consecutive-failure threshold — the kill is dropped, **not** sent to the other + channel. Disabling a channel must mean silence for that class of kill, not + silent misdirection into a channel the user did not choose. For a public + character channel that is a privacy question, not just a preference. + + Do not "fix" this into a reroute. `RouterTest` asserts it deliberately. + """ + + alias WandererApp.SystemClass + + @type verdict :: WandererApp.ExternalEvents.Discord.Matcher.verdict() + + @doc """ + Resolves one killmail to a destination. `notification` must have `:webhooks` + loaded. + """ + @spec route(map(), struct(), verdict()) :: {:ok, struct()} | :drop + def route(kill, notification, verdict) do + involved? = match?({:involved, _}, verdict) + system_id = kill["solar_system_id"] + + cond do + not involved? and system_id in (notification.excluded_systems || []) -> + :drop + + not involved? and notification.wh_only and not SystemClass.wormhole_system?(system_id) -> + :drop + + involved? -> + # Fallback to the system webhook when the character channel is not + # configured at all. `nil` here means "not configured"; a configured but + # disabled row is a different thing and is handled by `usable/1`. + usable(webhook(notification, :character) || webhook(notification, :system)) + + true -> + usable(webhook(notification, :system)) + end + end + + # Guarded on `is_list`: `:webhooks` is a relationship, so an unloaded + # notification carries `%Ash.NotLoaded{}` here. Reading `.role` off that would + # raise on the dispatch path; treating it as "no destination" drops instead, + # which is the conservative direction. + defp webhook(%{webhooks: webhooks}, role) when is_list(webhooks) do + Enum.find(webhooks, &(&1.role == role)) + end + + defp webhook(_notification, _role), do: nil + + defp usable(nil), do: :drop + defp usable(%{enabled?: false}), do: :drop + defp usable(webhook), do: {:ok, webhook} +end diff --git a/lib/wanderer_app/external_events/discord/system_name.ex b/lib/wanderer_app/external_events/discord/system_name.ex new file mode 100644 index 000000000..fdd5d8836 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/system_name.ex @@ -0,0 +1,82 @@ +defmodule WandererApp.ExternalEvents.Discord.SystemName do + @moduledoc """ + Resolves the system name shown on a killmail embed, per destination role. + + Map-local system names (`temporary_name`, then `custom_name`) appear on the + system webhook only. The character webhook always shows the canonical EVE name. + + This is a privacy boundary, not a formatting preference. Corporations commonly + keep the character-kill channel public so members without map access can see + kills and losses. Map-local chain naming in that channel leaks the map's + private naming to people who were deliberately not granted map access, and a + message posted to a public channel cannot be recalled. + + Resolution order on the system webhook: `temporary_name` -> `custom_name` -> + canonical name. + + This rule looks like an inconsistency and will invite a "fix." It gets a + regression test named for the constraint, and this paragraph is the reason a + reviewer should find when they go looking. + """ + + require Logger + + alias WandererApp.Api.MapSystem + + @type role :: :system | :character + + @doc """ + The system name to render for `role`. + + Returns `nil` when no name can be resolved at all; the formatter renders + "Unknown system" in that case rather than guessing. + """ + @spec display_name(String.t(), integer(), role()) :: String.t() | nil + def display_name(_map_id, solar_system_id, :character), do: canonical_name(solar_system_id) + + def display_name(map_id, solar_system_id, :system) do + map_local_name(map_id, solar_system_id) || canonical_name(solar_system_id) + end + + defp map_local_name(map_id, solar_system_id) + when is_binary(map_id) and is_integer(solar_system_id) do + # NOTE: `read_by_map_and_solar_system`, not `by_map_id_and_solar_system_id`. + # The latter targets the primary `:read` action, whose + # `FilterSystemsByActorMap` preparation filters to nothing when there is no + # actor in context — and there never is one here, because the dispatcher + # runs from a GenServer. It would return nil for every system and silently + # collapse the two roles into one. + case MapSystem.read_by_map_and_solar_system(%{ + map_id: map_id, + solar_system_id: solar_system_id + }) do + {:ok, %{} = system} -> + present(system.temporary_name) || present(system.custom_name) + + _ -> + nil + end + rescue + error -> + Logger.debug(fn -> + "[SystemName] map-local lookup failed for #{map_id}/#{solar_system_id}: #{inspect(error)}" + end) + + nil + end + + defp map_local_name(_map_id, _solar_system_id), do: nil + + defp canonical_name(solar_system_id) do + case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do + {:ok, %{solar_system_name: name}} -> present(name) + _ -> nil + end + rescue + _ -> nil + end + + defp present(nil), do: nil + defp present(""), do: nil + defp present(value) when is_binary(value), do: value +end diff --git a/lib/wanderer_app/external_events/discord/worker.ex b/lib/wanderer_app/external_events/discord/worker.ex new file mode 100644 index 000000000..84180cef4 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/worker.ex @@ -0,0 +1,422 @@ +defmodule WandererApp.ExternalEvents.Discord.Worker do + @moduledoc """ + Serializes Discord delivery for one webhook. + + Discord rate-limits a webhook to roughly 5 requests/second and answers 429 + with a `retry-after`. Everything for a webhook funnels through this process so + concurrent kill batches cannot interleave and burst. + + A map has up to two destinations (system and character), and they get separate + workers on purpose: a 429 or a dead URL on one channel must not stall or + disable the other. + + ## Asynchronous: never blocks on the network + + Each request and each retry is scheduled with `Process.send_after/3` and + handled in `handle_info`, so the process returns to its mailbox between + attempts. This is deliberate: a worker that sleeps inside `handle_cast` + cannot process incoming casts, which would make the 100-item queue bound + meaningless — events would pile up unbounded in the mailbox instead of being + dropped by the cap. (The existing `WebhookDispatcher` sleeps inside its retry + loop at `webhook_dispatcher.ex:355,376`; it is not the model here.) + + The HTTP request itself runs in a monitored `Task` for the same reason: a + synchronous call would block the process for up to the client's 15s receive + timeout, and a queue bound the mailbox routes around is not a bound at all. + + The database calls are the deliberate exception, and the guarantee is + specifically "never blocks on the *network*", not "never blocks". The + notification reload in `attempt/1` is synchronous because it must be ordered + before the send — the whole point is that no request goes out against a stale + record, which an async reload could not guarantee. The status write shares + that call's result. Under Ecto pool exhaustion (prod `queue_target` 5s) these + can block, which is the same mailbox-accumulation failure mode via the DB + rather than the socket; it is bounded by the pool timeout and accepted here. + + ## Ids, not records + + The queue holds messages only; the webhook id lives in state. The webhook row + is reloaded from the database immediately before every send, so a URL the user + has replaced or deleted is never used, and a stale `consecutive_failures` + snapshot cannot corrupt the counter. + + If the reload finds the webhook deleted, or finds `enabled?` false, the queued + event is dropped silently: no request, and no status write. There is nothing + meaningful to record against a row the user removed, and writing a failure + onto a row they deliberately disabled would be misleading. + + ## Per-event status + + Delivery status is tracked per *event*, not per request: a multi-chunk event + is only a success once every chunk lands, and an early successful chunk never + clears an error recorded by a later one. + + ## Limits + + An event gets at most 5 attempts per chunk and is subject to a ~60s deadline. + The deadline is checked before dispatching an attempt, not while a request is + in flight, so an event can overrun by up to one request duration (worst case + ~75s with the client's 15s timeout). It is a bound on *starting* new work, not + a hard wall-clock cap; nothing downstream depends on the exact figure. + """ + + use GenServer, restart: :transient + + require Logger + + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.ExternalEvents.Discord.HttpClient + + @idle_timeout :timer.seconds(60) + @max_queue 100 + @max_attempts 5 + @event_deadline_ms 60_000 + @max_retry_after_ms 10_000 + @min_retry_after_ms 50 + @default_retry_after_ms 1_000 + @backoff_base_ms 1_000 + @max_backoff_ms 8_000 + # Discord allows a webhook roughly 5 requests/second. A multi-chunk event + # posted back-to-back would burst straight into a 429 and burn attempts, so + # chunks are spaced just over that budget. + @inter_chunk_delay_ms 250 + + def start_link(opts) do + webhook_id = Keyword.fetch!(opts, :webhook_id) + registry = Keyword.fetch!(opts, :registry) + GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry, webhook_id}}) + end + + @doc """ + Queues one event's messages for delivery. + + No id argument: the worker IS the webhook now, so the queue holds messages + alone and the id comes from state. + """ + def enqueue(pid, messages) do + GenServer.cast(pid, {:enqueue, messages}) + end + + @impl true + def init(opts) do + # Both timeouts are overridable so tests can exercise idle shutdown and + # deadline expiry without waiting a real minute. Production always uses the + # module defaults. + idle_timeout = Keyword.get(opts, :idle_timeout, @idle_timeout) + + {:ok, + %{ + webhook_id: Keyword.fetch!(opts, :webhook_id), + idle_timeout: idle_timeout, + event_deadline_ms: Keyword.get(opts, :event_deadline_ms, @event_deadline_ms), + queue: :queue.new(), + queue_len: 0, + # nil when idle, otherwise the event currently being delivered + current: nil + }, idle_timeout} + end + + @impl true + def handle_cast({:enqueue, messages}, state) do + state = + state + |> push(messages) + |> maybe_start_next() + + {:noreply, state, state.idle_timeout} + end + + @impl true + def handle_info(:attempt, %{current: nil} = state) do + state = maybe_start_next(state) + {:noreply, state, state.idle_timeout} + end + + def handle_info(:attempt, state) do + state = attempt(state) + {:noreply, state, state.idle_timeout} + end + + # Reply from the in-flight request task. + def handle_info({ref, result}, %{current: %{task_ref: ref}} = state) when is_reference(ref) do + # Demonitor first: the task is about to send its :DOWN, and we do not want + # to treat normal completion as a crash. + Process.demonitor(ref, [:flush]) + state = handle_post_result(state, result) + {:noreply, state, state.idle_timeout} + end + + # The request task crashed. Treat it as a transient failure and retry, rather + # than losing the event: async_nolink means this does not take the worker down. + def handle_info({:DOWN, ref, :process, _pid, reason}, %{current: %{task_ref: ref}} = state) + when is_reference(ref) do + state = put_current(state, %{state.current | task_ref: nil}) + + state = + schedule_retry( + state, + backoff_ms(state.current.attempt), + "request crashed: #{inspect(reason)}" + ) + + {:noreply, state, state.idle_timeout} + end + + # A late reply or DOWN from a task we already gave up on — ignore it. + def handle_info({ref, _result}, state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {:noreply, state, state.idle_timeout} + end + + def handle_info({:DOWN, ref, :process, _pid, _reason}, state) when is_reference(ref) do + {:noreply, state, state.idle_timeout} + end + + def handle_info(:timeout, %{queue_len: 0, current: nil} = state) do + {:stop, :normal, state} + end + + def handle_info(:timeout, state), do: {:noreply, state, state.idle_timeout} + + def handle_info(msg, state) do + Logger.debug("[Discord.Worker] unexpected message: #{inspect(msg)}") + {:noreply, state, state.idle_timeout} + end + + # -- queue ---------------------------------------------------------------- + + defp push(%{queue_len: len} = state, item) when len >= @max_queue do + # Drop the oldest: a feed 100 messages behind has already failed its + # purpose, and unbounded growth risks the VM. queue_len is unchanged + # because one item leaves as one enters. + {{:value, _dropped}, q} = :queue.out(state.queue) + + Logger.warning( + "[Discord.Worker] queue full for webhook #{state.webhook_id}, dropping oldest event" + ) + + %{state | queue: :queue.in(item, q)} + end + + defp push(state, item) do + %{state | queue: :queue.in(item, state.queue), queue_len: state.queue_len + 1} + end + + defp maybe_start_next(%{current: current} = state) when not is_nil(current), do: state + + defp maybe_start_next(state) do + case :queue.out(state.queue) do + {:empty, _} -> + state + + {{:value, messages}, rest} -> + current = %{ + pending: messages, + attempt: 1, + task_ref: nil, + # Most recently loaded record, reused for the status write so + # finishing an event does not re-query what we just read. + webhook: nil, + deadline: System.monotonic_time(:millisecond) + state.event_deadline_ms + } + + send(self(), :attempt) + %{state | queue: rest, queue_len: state.queue_len - 1, current: current} + end + end + + # -- one attempt ---------------------------------------------------------- + + defp attempt(%{current: %{pending: []}} = state), do: finish(state, :ok) + + defp attempt(%{current: current} = state) do + cond do + System.monotonic_time(:millisecond) > current.deadline -> + finish(state, {:error, "delivery deadline exceeded", :count}) + + current.attempt > @max_attempts -> + finish(state, {:error, "gave up after #{@max_attempts} attempts", :count}) + + true -> + # Reload every time: the URL may have been replaced or the webhook + # deleted since this event was queued. This reload is the one that + # matters — nothing is sent against a stale record. + case MapDiscordWebhook.by_id(state.webhook_id) do + {:ok, webhook} -> + state = put_current(state, %{current | webhook: webhook}) + + if webhook.enabled? do + do_post(state, webhook) + else + # Disabled while queued — drop the event silently, no status write. + drop_current(state) + end + + _ -> + Logger.debug("[Discord.Worker] webhook gone, dropping queued event") + drop_current(state) + end + end + end + + # Runs the request in a monitored Task so the worker never blocks on the + # socket. The HTTP client can take up to its 15s receive timeout; blocking + # here would let casts pile up in the mailbox and silently defeat the + # bounded state queue — the same defect as sleeping, just harder to see. + defp do_post(%{current: current} = state, webhook) do + [message | _rest] = current.pending + url = webhook.webhook_url + + task = + Task.Supervisor.async_nolink( + WandererApp.ExternalEvents.Discord.TaskSupervisor, + fn -> HttpClient.post(url, message) end + ) + + put_current(state, %{current | task_ref: task.ref}) + end + + defp handle_post_result(state, result) do + current = state.current + [_sent | rest] = current.pending + + case result do + {:ok, status, _headers} when status in 200..299 -> + # Chunk delivered: move on with a fresh attempt budget, same deadline. + state = put_current(state, %{current | pending: rest, attempt: 1, task_ref: nil}) + + if rest == [] do + finish(state, :ok) + else + # Spaced, not immediate: back-to-back chunks would trip the webhook's + # own rate limit and turn a successful event into a run of 429s. + Process.send_after(self(), :attempt, @inter_chunk_delay_ms) + state + end + + {:ok, 429, headers} -> + state = put_current(state, %{current | task_ref: nil}) + schedule_retry(state, retry_after_ms(headers), "Discord returned 429 (rate limited)") + + {:ok, 404, _headers} -> + # The only status that disables immediately: the webhook was deleted + # upstream and will never recover. + finish(state, {:error, "Discord returned 404 — webhook was deleted", :disable}) + + {:ok, status, _headers} when status in 400..499 -> + # 401/403 and any other 4xx are permanent for this event but do NOT + # disable on their own — they feed the 10-consecutive-failure + # threshold, so a single transient 403 cannot kill a map's feed. + finish(state, {:error, "Discord returned #{status}", :count}) + + {:ok, status, _headers} -> + state = put_current(state, %{current | task_ref: nil}) + schedule_retry(state, backoff_ms(current.attempt), "Discord returned #{status}") + + {:error, reason} -> + state = put_current(state, %{current | task_ref: nil}) + schedule_retry(state, backoff_ms(current.attempt), "request failed: #{inspect(reason)}") + end + end + + # Schedules the next attempt instead of sleeping, so the mailbox keeps moving. + defp schedule_retry(%{current: current} = state, delay_ms, reason) do + next_attempt = current.attempt + 1 + + if next_attempt > @max_attempts do + finish(state, {:error, reason, :count}) + else + Process.send_after(self(), :attempt, delay_ms) + put_current(state, %{current | attempt: next_attempt}) + end + end + + defp put_current(state, current), do: %{state | current: current} + + defp drop_current(state) do + state |> put_current(nil) |> maybe_start_next() + end + + # -- event completion ----------------------------------------------------- + + # Reuses the record `attempt/1` loaded moments ago rather than re-querying. + # The reload that matters for correctness is the one *before the send*; this + # is only the status write, and `record_failure` re-reads the counter inside + # the resource action anyway, so a slightly stale copy here cannot corrupt it. + # Falls back to a query when no record was loaded (e.g. the deadline expired + # before the first attempt). + defp finish(%{current: current} = state, outcome) do + case current.webhook do + nil -> record_outcome(state.webhook_id, outcome) + webhook -> apply_outcome(webhook, outcome) + end + + drop_current(state) + end + + defp record_outcome(webhook_id, outcome) do + case MapDiscordWebhook.by_id(webhook_id) do + {:ok, webhook} -> apply_outcome(webhook, outcome) + _ -> :ok + end + end + + defp apply_outcome(webhook, :ok) do + case MapDiscordWebhook.record_success(webhook) do + {:ok, _} -> :ok + {:error, reason} -> Logger.warning("[Discord] record_success failed: #{inspect(reason)}") + end + end + + defp apply_outcome(webhook, {:error, reason, :disable}) do + case MapDiscordWebhook.disable(webhook, to_string(reason)) do + {:ok, _} -> :ok + {:error, err} -> Logger.warning("[Discord] disable failed: #{inspect(err)}") + end + end + + defp apply_outcome(webhook, {:error, reason, :count}) do + # record_failure disables at @max_consecutive_failures on the resource side. + case MapDiscordWebhook.record_failure(webhook, to_string(reason)) do + {:ok, _} -> :ok + {:error, err} -> Logger.warning("[Discord] record_failure failed: #{inspect(err)}") + end + end + + # -- timing helpers ------------------------------------------------------- + + defp retry_after_ms(headers) do + headers + |> Enum.find_value(fn {k, v} -> + if String.downcase(k) == "retry-after", do: v + end) + |> parse_retry_after() + end + + defp parse_retry_after(nil), do: @default_retry_after_ms + + defp parse_retry_after(value) do + # Clamped to @max_retry_after_ms. Tradeoff, stated explicitly: if Discord + # asks for a wait longer than 10s we retry sooner than requested and burn + # an attempt, so a heavily rate-limited event can exhaust its 5 attempts + # and be recorded as a failure rather than waiting the full window. We + # accept that to keep the worker's queue moving — a 60s honored wait would + # stall every other event for this map behind one rate-limited chunk, and + # the event deadline would likely kill it anyway. + case Float.parse(to_string(value)) do + {seconds, _} -> + seconds + |> Kernel.*(1000) + |> round() + |> min(@max_retry_after_ms) + |> max(@min_retry_after_ms) + + :error -> + @default_retry_after_ms + end + end + + defp backoff_ms(attempt) do + (@backoff_base_ms * :math.pow(2, attempt - 1)) |> min(@max_backoff_ms) |> round() + end +end diff --git a/lib/wanderer_app/external_events/discord/worker_supervisor.ex b/lib/wanderer_app/external_events/discord/worker_supervisor.ex new file mode 100644 index 000000000..ffb84e390 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/worker_supervisor.ex @@ -0,0 +1,145 @@ +defmodule WandererApp.ExternalEvents.Discord.WorkerSupervisor do + @moduledoc """ + Starts one delivery worker per Discord webhook on demand, addressed through a + Registry keyed by webhook id. + + Per webhook, not per map: Discord's rate limits are per webhook, and a failure + must be attributable to the destination that caused it. Sharing a worker + between a map's system and character channels would let a 429 on one stall the + other, and a 404 on one disable both. + + Workers are transient: they own an in-memory queue, shut down when idle, and + are not restarted with their queue intact. Losing a queued notification on + crash is acceptable; duplicating a delivered one is not. + """ + + use Supervisor + + require Logger + + alias WandererApp.ExternalEvents.Discord.Worker + + @registry WandererApp.ExternalEvents.Discord.Registry + @dyn_sup WandererApp.ExternalEvents.Discord.DynamicSupervisor + # Bounded so a worker wedged on a slow DB call cannot block the destroy that + # is stopping it. The exit is caught either way; the worker is brought down. + @stop_timeout_ms 5_000 + + def start_link(opts \\ []), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + children = [ + {Registry, keys: :unique, name: @registry}, + {Task.Supervisor, name: WandererApp.ExternalEvents.Discord.TaskSupervisor}, + {DynamicSupervisor, name: @dyn_sup, strategy: :one_for_one} + ] + + # :rest_for_one, not :one_for_one — workers register in the Registry, so a + # Registry crash would leave them running but unreachable, and the next + # deliver/2 would start a *second* worker for the same webhook and + # double-post. Restarting everything after the Registry clears those orphans. + Supervisor.init(children, strategy: :rest_for_one) + end + + @doc """ + Enqueues messages for one webhook, starting its worker if it is not running. + + Takes the webhook *id*, never the record: the worker reloads it just before + each send so a replaced or deleted webhook is not used, and so a stale + `consecutive_failures` snapshot cannot corrupt the counter. + + Returns `{:error, :not_running}` when the worker infrastructure is not + started (e.g. webhooks globally disabled), mirroring `stop_worker/1`'s + tolerance of the same condition. Callers on the dispatch path must not crash + just because the kill-switch is off. + """ + def deliver(_webhook_id, []), do: :ok + + def deliver(webhook_id, messages) do + case ensure_worker(webhook_id) do + {:ok, pid} -> + Worker.enqueue(pid, messages) + + {:error, :not_running} -> + # Not an error worth logging on every event: the kill-switch being off + # is a normal configuration, not a failure. + {:error, :not_running} + + {:error, reason} -> + Logger.warning( + "[Discord] could not start worker for webhook #{webhook_id}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + + @doc """ + Stops one webhook's delivery worker if one is running, discarding its queue. + + Called from the webhook resource's destroy, and from the parent notification's + destroy for each of its children: without it, a removed webhook keeps + receiving whatever was already queued. A no-op when the worker infrastructure + is not running at all (e.g. webhooks globally disabled, or in tests that do + not start this supervisor). + """ + def stop_worker(webhook_id) do + case Process.whereis(@registry) do + nil -> + :ok + + _ -> + case Registry.lookup(@registry, webhook_id) do + # The worker may have idled out or crashed between the lookup and the + # stop; either way the post-condition (no worker running) holds. + [{pid, _}] -> try_stop(pid) + [] -> :ok + end + + :ok + end + end + + defp try_stop(pid) do + GenServer.stop(pid, :normal, @stop_timeout_ms) + catch + # Already gone, or did not terminate within the timeout — in the latter case + # GenServer.stop/3 has already killed it. Either way there is no worker left. + :exit, _ -> :ok + end + + defp ensure_worker(webhook_id) do + # Guard exactly as stop_worker/1 does: Registry.lookup on an unregistered + # name raises ArgumentError, which would crash the dispatcher whenever + # webhooks are globally disabled and this supervisor was never started. + case Process.whereis(@registry) do + nil -> {:error, :not_running} + _ -> lookup_or_start(webhook_id) + end + end + + defp lookup_or_start(webhook_id) do + case Registry.lookup(@registry, webhook_id) do + # Registry releases a dead owner's key asynchronously, so a lookup can + # still return a pid that has just exited (idle shutdown or stop_worker). + [{pid, _}] when is_pid(pid) -> + if Process.alive?(pid), do: {:ok, pid}, else: start_worker(webhook_id) + + [] -> + start_worker(webhook_id) + end + end + + defp start_worker(webhook_id) do + spec = {Worker, webhook_id: webhook_id, registry: @registry} + + case DynamicSupervisor.start_child(@dyn_sup, spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + error -> error + end + end + + def registry, do: @registry +end diff --git a/lib/wanderer_app/external_events/discord_dispatcher.ex b/lib/wanderer_app/external_events/discord_dispatcher.ex new file mode 100644 index 000000000..8a8890b78 --- /dev/null +++ b/lib/wanderer_app/external_events/discord_dispatcher.ex @@ -0,0 +1,504 @@ +defmodule WandererApp.ExternalEvents.DiscordDispatcher do + @moduledoc """ + Delivers `:map_kill` events to a map's configured Discord webhook. + + A sibling of `WebhookDispatcher`, not a variant of it: Discord ignores HMAC + signatures and the `X-Wanderer-*` headers, requires its own body shape, and + enforces its own rate limits. + + ## Why a GenServer + + `dispatch_event/2` is a cast, matching `WebhookDispatcher` + (`webhook_dispatcher.ex:16,42-43`). `MapEventRelay` calls it inline, and the + work here — config lookup, system-class resolution, formatting — involves + cache misses that hit the database. Doing that on the relay's process would + delay SSE and generic webhook delivery for every other subscriber. + + Responsibilities here are filtering and deduplication; serialized HTTP + delivery belongs to the per-map worker. + + ## Deduplication is at-most-once, by choice + + The dedup key is `"\#{map_id}:\#{killmail_id}"` and is deliberately NOT scoped by + webhook role. A kill posts once per map, to one destination — routing chooses + which. Scoping the key by role would double-post any kill eligible for both. + + Killmails are marked as *attempted* before delivery is confirmed, so an event + lost to a delivery failure is never re-sent. This is deliberate. Marking only + after success would require holding the batch across an async worker + round-trip and would still race on a crash between send and mark. Of the two + failure modes — post a kill twice, or silently drop one — a duplicate post in + a chat channel is irreversible and worse; a dropped kill is still visible in + the kills widget and on zKillboard. + + This is not a delivery guarantee. It is an explicit decision to lose the + occasional kill rather than ever double-post one. + + The one exception is `{:error, :not_running}` from the worker supervisor, + which means nothing was enqueued at all: those marks are released, since no + request can possibly have gone out and therefore no duplicate is possible. + + The rationale covers losses to *delivery failure* only. Kills past the + formatter's per-destination cap are never rendered into a message, so they are + not marked at all and stay eligible if they arrive again. The same holds for + kills the router drops: they belong to no partition and are never marked. + """ + + use GenServer + + require Logger + + alias WandererApp.Api.{MapDiscordNotification, MapDiscordWebhook} + alias WandererApp.Env + alias WandererApp.ExternalEvents.Discord.{EmbedFormatter, Matcher, Router, SystemName} + alias WandererApp.ExternalEvents.Discord.WorkerSupervisor + + @cache :discord_notification_cache + @dedup_cache :discord_dedup_cache + # Comfortably longer than any plausible upstream replay window, matching the + # 24h TTLs already used for kill caches. + @dedup_ttl :timer.hours(24) + + def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts), do: {:ok, %{}} + + @doc """ + Entry point called by `MapEventRelay` for every external event. + + A cast: the relay must never block on Discord-side work. + """ + @spec dispatch_event(map_id :: String.t(), struct()) :: :ok + def dispatch_event(map_id, event) do + GenServer.cast(__MODULE__, {:dispatch_event, map_id, event}) + end + + @doc """ + Posts a fixed sample message to ONE webhook so a user can confirm it works. + Routed through the same worker so it cannot jump the queue. + + Takes a webhook id, not a map id: a map can have two destinations and the + user tests them separately. + + Reports *configuration* errors synchronously, each as its own atom, because + they ask the user for different things: + + * `:notifications_disabled` — the global kill-switch is off. Only an + administrator can change it. + * `:webhook_not_found` — no row with that id, in practice a stale page whose + destination was deleted in another session. + * `:webhook_url_missing` — the row exists but carries no usable URL. + * `:webhook_disabled` — the row exists and has a URL; `enabled?` is false. + Nothing is wrong with the configuration, it is switched off. + + These were one `:not_configured` until the Task 16 gate. Telling a user to + save a webhook URL when they had already saved one and merely unticked the + box is why they were split; `map_notifications_component.ex` had to + re-derive the disabled case from its own assigns to avoid printing that. + + Delivery success is **not** awaited. The final hop is `Worker.enqueue/2`, a + cast, so `:ok` means "accepted for delivery", not "Discord accepted it" — a + dead or revoked webhook URL still returns `:ok` here and surfaces later as a + failure recorded on the webhook record (`last_error`, `consecutive_failures`). + UI built on this must not promise the user that the message arrived. + """ + @spec send_test_message(webhook_id :: String.t()) :: + :ok + | {:error, + :notifications_disabled + | :webhook_not_found + | :webhook_url_missing + | :webhook_disabled + | term()} + def send_test_message(webhook_id) do + # Checked here rather than inside the worker: when the gate is off the + # worker supervisor and its Registry are not running at all, so calling + # into them would crash the caller (the LiveView). + if enabled_globally?() do + with {:ok, webhook} <- resolve_test_webhook(webhook_id) do + message = %{ + "content" => "Wanderer test message — Discord kill notifications are configured." + } + + case WorkerSupervisor.deliver(webhook.id, [message]) do + :ok -> + :ok + + # The gate read as on, but the worker tree is not up (e.g. the app + # was started with webhooks disabled and the config flipped since). + # Report it rather than claiming the test message was sent. + {:error, :not_running} -> + {:error, :notifications_disabled} + + {:error, reason} -> + {:error, reason} + end + end + else + {:error, :notifications_disabled} + end + end + + # Clause order is the user-facing precedence and is load-bearing: a row that + # is BOTH disabled and URL-less reports `:webhook_disabled`. That preserves + # what the component rendered before this split, where its local `enabled?` + # check ran ahead of any call into the dispatcher. + defp resolve_test_webhook(webhook_id) do + case MapDiscordWebhook.by_id(webhook_id) do + {:ok, %{enabled?: false}} -> + {:error, :webhook_disabled} + + {:ok, %{webhook_url: url} = webhook} when is_binary(url) and url != "" -> + {:ok, webhook} + + {:ok, _webhook} -> + # `webhook_url` is `allow_nil? false` and validated on write, so this is + # not reachable through the UI. It is reachable through a hand-repaired + # row or a vault key that no longer decrypts the stored ciphertext — + # exactly the cases where "save a URL first" is the right advice and + # "no such destination" would send the operator looking in the wrong + # place. Deliberately NOT `valid_webhook_url?/1`: re-running the + # stricter write-time validator here would reclassify rows that were + # accepted when they were saved. + {:error, :webhook_url_missing} + + _ -> + {:error, :webhook_not_found} + end + end + + @doc """ + Drops the cached config for a map after its record changes. + + A plain function, not a GenServer call: it is invoked from Ash + after_transaction hooks that may run before this process exists. + """ + def invalidate_cache(map_id) do + Cachex.del(@cache, map_id) + :ok + rescue + # The cache is not started in every context (e.g. unit tests); a missing + # cache must not fail the write that triggered the invalidation. + _ -> :ok + end + + @impl true + def handle_cast({:dispatch_event, map_id, event}, state) do + do_dispatch(map_id, event) + {:noreply, state} + end + + @impl true + def handle_info(msg, state) do + Logger.debug(fn -> "[Discord] dispatcher received unexpected message: #{inspect(msg)}" end) + {:noreply, state} + end + + defp do_dispatch(map_id, %{type: :map_kill, payload: payload}) do + now = DateTime.utc_now() + + with true <- enabled_globally?(), + {:ok, notification} <- fetch_config(map_id), + true <- notification.enabled?, + {:ok, system_id, killmails} <- extract_kills(payload), + # Resolved ONCE per batch, not per kill: `kill_fresh?/3` runs once per + # killmail below, and re-reading (and re-validating) config on every + # one of potentially dozens of kills would turn a single misconfigured + # deployment into a warning-per-kill log flood. This binding, and the + # explicit third argument to `kill_fresh?/3` below, must survive any + # rewrite of this `with` chain — dropping either silently reopens that + # flood. Filtering for age happens HERE, once, before partitioning: + # moving it inside the per-destination loop reintroduces the flood. + max_killmail_age_seconds = Env.discord_max_killmail_age_seconds(), + # Stale kills (an upstream replay burst on reconnect) are filtered + # BEFORE dedup: a kill dropped here for age was never marked attempted, + # so it stays eligible if it arrives again inside the freshness window. + # It is also upstream of every partition, so no partition can mark a + # stale kill. + [_ | _] = recent <- + Enum.filter(killmails, &kill_fresh?(&1, now, max_killmail_age_seconds)), + [_ | _] = fresh <- reject_duplicates(map_id, recent) do + # System-level filtering used to happen here for the whole batch. It now + # lives in `Router.route/3`, because `excluded_systems` and `wh_only` have + # per-kill carve-outs for kills involving this map's own pilots. + fresh + |> partition(map_id, notification) + |> Enum.each(fn {webhook, entries} -> + deliver_partition(map_id, system_id, webhook, entries) + end) + + :ok + else + _ -> :ok + end + end + + defp do_dispatch(_map_id, _event), do: :ok + + # Routing is per kill, so a single `:map_kill` batch can now contain kills + # bound for different destinations, or for none. Kills that drop belong to no + # partition and are NEVER MARKED, so they stay eligible if they arrive again. + # + # Each entry keeps its verdict alongside the kill: the formatter needs it for + # colouring and title (loss vs kill). + defp partition(kills, map_id, notification) do + tracked = Matcher.tracked_eve_ids(map_id) + focus = notification.focus_corp_ids || [] + + kills + |> Enum.reduce(%{}, fn kill, acc -> + verdict = Matcher.involvement(kill, tracked, focus) + + case Router.route(kill, notification, verdict) do + {:ok, webhook} -> Map.update(acc, webhook, [{kill, verdict}], &[{kill, verdict} | &1]) + :drop -> acc + end + end) + # Reduce prepends; restore the batch's original order per destination. + |> Map.new(fn {webhook, entries} -> {webhook, Enum.reverse(entries)} end) + end + + # The role reaching `SystemName.display_name/3` is a LITERAL atom matched out + # of the destination itself, one clause per role — never `webhook.role` + # threaded through as a variable. That resolver is the privacy boundary + # (map-local chain names on the `:system` webhook only, because the character + # channel is commonly public), and it can only enforce that boundary if the + # role it receives is genuinely this destination's own. A variable reused + # across partitions is the one remaining leak path, and a Discord post cannot + # be recalled. An unknown role raises here instead of guessing — correct. + defp deliver_partition(map_id, system_id, %{role: :system} = webhook, entries) do + deliver_to( + map_id, + webhook, + :system, + SystemName.display_name(map_id, system_id, :system), + entries + ) + end + + defp deliver_partition(map_id, system_id, %{role: :character} = webhook, entries) do + deliver_to( + map_id, + webhook, + :character, + SystemName.display_name(map_id, system_id, :character), + entries + ) + end + + # Each non-empty partition is formatted and delivered independently. + defp deliver_to(map_id, webhook, role, system_name, entries) do + # THE CAP IS PER DESTINATION, NOT PER EVENT. It is a Discord message-size + # concern, so two destinations do not compete for one 30-kill budget. + rendered = Enum.take(entries, EmbedFormatter.max_kills_per_event()) + marked = Enum.map(rendered, fn {kill, _verdict} -> kill end) + + # Marked before delivery: see the moduledoc — this is at-most-once by + # choice, not an oversight. Only the kills the formatter will actually + # render are marked; kills past the cap are never turned into a message, so + # marking them would burn them for the full dedup TTL without ever sending + # them. + mark_attempted(map_id, marked) + + # PASS THE WHOLE PARTITION, NOT `rendered`. The formatter applies the cap + # itself and counts the remainder into its "…and N more kills not shown." + # line. Handing it the pre-truncated list compiles, passes most tests, and + # silently deletes the overflow line. + entries + |> EmbedFormatter.format_batch(system_name) + |> then(&WorkerSupervisor.deliver(webhook.id, &1)) + |> handle_delivery_result(map_id, role, marked) + end + + defp handle_delivery_result(:ok, map_id, role, kills) do + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :dispatched], + %{count: length(kills)}, + %{map_id: map_id, role: role} + ) + end + + # Nothing was enqueued, so no duplicate is possible: release this partition's + # dedup marks so a later event carrying these kills can still be delivered + # once the worker tree is up. Other partitions in the same event are + # unaffected — their marks stand or fall on their own delivery result. Not + # logged at warning level — the kill-switch being off is a normal + # configuration, not a failure. + defp handle_delivery_result({:error, :not_running}, map_id, role, kills) do + unmark(map_id, kills) + + Logger.debug(fn -> + "[Discord] worker infrastructure not running; dropped #{length(kills)} " <> + "#{role} kills for map #{map_id}" + end) + + emit_not_delivered(map_id, role, kills, :not_running) + end + + defp handle_delivery_result({:error, reason}, map_id, role, kills) do + Logger.warning( + "[Discord] #{role} delivery enqueue failed for map #{map_id}: #{inspect(reason)}" + ) + + emit_not_delivered(map_id, role, kills, reason) + end + + defp emit_not_delivered(map_id, role, kills, reason) do + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :not_delivered], + %{count: length(kills)}, + %{map_id: map_id, role: role, reason: reason} + ) + end + + defp enabled_globally?, do: WandererApp.Env.webhooks_enabled?() + + defp fetch_config(map_id) do + case Cachex.get(@cache, map_id) do + {:ok, nil} -> + load_and_cache(map_id) + + {:ok, :none} -> + {:error, :not_configured} + + {:ok, notification} -> + {:ok, notification} + + _ -> + load_and_cache(map_id) + end + end + + defp load_and_cache(map_id) do + # `:webhooks` is loaded here, once, and rides along in the cached struct: the + # dispatch path must not query the destination table per killmail. Every + # child create/update/destroy invalidates this entry (Task 1), so a newly + # added or removed webhook is picked up immediately rather than after the + # 5-minute TTL. + with {:ok, notification} when not is_nil(notification) <- + MapDiscordNotification.by_map(map_id), + {:ok, notification} <- Ash.load(notification, :webhooks) do + Cachex.put(@cache, map_id, notification) + {:ok, notification} + else + _ -> + # Cache the negative result too, so busy unconfigured maps do not + # hit the database on every killmail. + Cachex.put(@cache, map_id, :none) + {:error, :not_configured} + end + end + + # Only killmail batches are interesting; `:kill_count` updates carry no + # killmails and would be noise in a channel. + defp extract_kills(%{"type" => :killmail_update} = payload) do + case payload["killmails"] do + [_ | _] = kills -> {:ok, payload["solar_system_id"], kills} + _ -> :skip + end + end + + defp extract_kills(_), do: :skip + + # The `seen` accumulator matters as much as the cache lookup: `mark_attempted/2` + # only runs after the whole batch is filtered, so a batch carrying the same + # killmail_id twice passed both copies through and posted the kill twice. + defp reject_duplicates(map_id, killmails) do + {kept, _seen} = + Enum.reduce(killmails, {[], MapSet.new()}, fn kill, {kept, seen} -> + key = dedup_key(map_id, kill) + + cached? = + case Cachex.exists?(@dedup_cache, key) do + {:ok, true} -> true + _ -> false + end + + if cached? or MapSet.member?(seen, key) do + {kept, seen} + else + {[kill | kept], MapSet.put(seen, key)} + end + end) + + Enum.reverse(kept) + end + + # Records that we have *attempted* this killmail, not that Discord accepted + # it. Named accordingly so the at-most-once semantics are not misread. + defp mark_attempted(map_id, killmails) do + Enum.each(killmails, fn kill -> + Cachex.put(@dedup_cache, dedup_key(map_id, kill), true, ttl: @dedup_ttl) + end) + end + + defp unmark(map_id, killmails) do + Enum.each(killmails, fn kill -> Cachex.del(@dedup_cache, dedup_key(map_id, kill)) end) + end + + @doc """ + Dedup cache key for one killmail, by kill map or by bare killmail id. + + Public so tests derive the key instead of hardcoding its format: a hardcoded + key makes `refute`-style assertions ("this kill was never marked") pass + vacuously the moment the format changes. + + NOT role-scoped, deliberately: exactly one destination is chosen per kill, + so one key per (map, killmail) is exactly right. Should a future change ever + post one kill to BOTH channels, this key must gain the role — otherwise the + first post marks the kill and the second is silently suppressed. + """ + @spec dedup_key(String.t(), map() | integer() | String.t()) :: String.t() + def dedup_key(map_id, kill) when is_map(kill), do: dedup_key(map_id, kill["killmail_id"]) + def dedup_key(map_id, killmail_id), do: "#{map_id}:#{killmail_id}" + + @doc "Name of the dedup cache, so tests do not hardcode it either." + @spec dedup_cache() :: atom() + def dedup_cache, do: @dedup_cache + + @doc """ + Whether a killmail is recent enough to post. + + Guards against an upstream replay burst on reconnect dumping hours of history + into a chat channel, and is the precondition that would make a join-time + preload safe if one is ever added. + + **Fail-open on purpose.** An absent, non-string, or unparseable `kill_time` + allows the kill through, matching the dispatcher's posture everywhere else: a + malformed field must not silently suppress notifications. Do not change this + to fail-closed — a parse regression would then look exactly like a quiet map. + + `now` is an argument rather than an internal `DateTime.utc_now/0` call so the + boundary cases are testable without sleeping or freezing the clock. + + `max_age_seconds` is likewise an argument, not read from `Env` here: this + runs once per killmail, and `do_dispatch/2` resolves it ONCE per batch and + passes it down explicitly. Reading `Env.discord_max_killmail_age_seconds/0` + per kill would mean a misconfigured value (see `Env`'s own validation) + re-logs its warning once per kill instead of once per batch. The default + here exists only so this function stays directly callable with two + arguments in tests; `do_dispatch/2` always supplies the third explicitly. + """ + @spec kill_fresh?(map(), DateTime.t(), pos_integer()) :: boolean() + def kill_fresh?( + kill, + now \\ DateTime.utc_now(), + max_age_seconds \\ Env.discord_max_killmail_age_seconds() + ) + + def kill_fresh?(%{"kill_time" => kill_time}, now, max_age_seconds) when is_binary(kill_time) do + case DateTime.from_iso8601(kill_time) do + {:ok, killed_at, _utc_offset} -> + # Positive when the kill is in the past. A future-dated kill_time gives a + # negative age and passes, which is the intent: this guard is about + # staleness only. + DateTime.diff(now, killed_at, :second) <= max_age_seconds + + {:error, _reason} -> + true + end + end + + def kill_fresh?(_kill, _now, _max_age_seconds), do: true +end diff --git a/lib/wanderer_app/external_events/map_event_relay.ex b/lib/wanderer_app/external_events/map_event_relay.ex index 30e563eed..b2475bcbb 100644 --- a/lib/wanderer_app/external_events/map_event_relay.ex +++ b/lib/wanderer_app/external_events/map_event_relay.ex @@ -159,6 +159,10 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do WebhookDispatcher.dispatch_event(event.map_id, event) + # Also a cast, so Discord config lookups and formatting never delay SSE or + # generic webhook delivery on this process. + WandererApp.ExternalEvents.DiscordDispatcher.dispatch_event(event.map_id, event) + case WandererApp.ExternalEvents.SseAccessControl.sse_allowed?(event.map_id) do :ok -> WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json) diff --git a/test/support/discord_http_stub.ex b/test/support/discord_http_stub.ex new file mode 100644 index 000000000..03da5da3a --- /dev/null +++ b/test/support/discord_http_stub.ex @@ -0,0 +1,66 @@ +defmodule WandererApp.ExternalEvents.Discord.HttpStub do + @moduledoc """ + Test double for Discord HTTP delivery. + + State lives in ONE named Agent shared by every test, so any test using this + stub must be `async: false`. Call `start/0` in setup (it resets the state if + the Agent is already up), `set_responses/1` to script replies, and + `requests/0` to assert on what was sent. + """ + @behaviour WandererApp.ExternalEvents.Discord.HttpClient + + @agent __MODULE__.Agent + + def start do + # `Agent.start`, not `start_link`: linking would tie the shared Agent to + # whichever test process happened to call `start/0` first, so it would die + # with that test. A worker Task still in flight afterwards would then hit a + # dead Agent, exit `:noproc`, and record a spurious delivery failure instead + # of the scripted response. + case Agent.start(fn -> new_state() end, name: @agent) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> reset() && {:ok, pid} + {:error, reason} -> raise "could not start #{inspect(@agent)}: #{inspect(reason)}" + end + end + + def reset, do: Agent.update(@agent, fn _ -> new_state() end) == :ok + + defp new_state, do: %{responses: [], by_url: %{}, requests: []} + + @doc "Queues responses for ANY url, consumed in order. Each is {:ok, status, headers} or {:error, term}." + def set_responses(responses), do: Agent.update(@agent, &%{&1 | responses: responses}) + + @doc """ + Queues responses for ONE url, consumed in order and checked before the global + queue. Needed once a map has two webhooks: with a single global queue the two + destinations race for the scripted reply, so "404 the character webhook" would + land on whichever request happened to arrive first. + """ + def set_responses_for(url, responses), + do: Agent.update(@agent, &%{&1 | by_url: Map.put(&1.by_url, url, responses)}) + + @doc "Returns {url, body} tuples in the order they were sent." + def requests, do: Agent.get(@agent, & &1.requests) |> Enum.reverse() + + @doc "Returns the {url, body} tuples sent to one url, in order." + def requests_for(url), do: Enum.filter(requests(), fn {u, _body} -> u == url end) + + @impl true + def post(url, body) do + Agent.get_and_update(@agent, fn state -> + state = %{state | requests: [{url, body} | state.requests]} + + case Map.get(state.by_url, url) do + [resp | rest] -> + {resp, %{state | by_url: Map.put(state.by_url, url, rest)}} + + _ -> + case state.responses do + [] -> {{:ok, 204, []}, state} + [resp | rest] -> {resp, %{state | responses: rest}} + end + end + end) + end +end diff --git a/test/unit/external_events/discord/embed_formatter_test.exs b/test/unit/external_events/discord/embed_formatter_test.exs new file mode 100644 index 000000000..c0b921966 --- /dev/null +++ b/test/unit/external_events/discord/embed_formatter_test.exs @@ -0,0 +1,590 @@ +defmodule WandererApp.ExternalEvents.Discord.EmbedFormatterTest do + use ExUnit.Case, async: true + + alias WandererApp.ExternalEvents.Discord.EmbedFormatter + alias WandererAppWeb.Factory + + @loss {:involved, :victim} + @kill {:involved, :attacker} + @bystander :not_involved + + describe "colour" do + test "a loss is red" do + kill = Factory.build(:killmail) + assert EmbedFormatter.format_kill(kill, @loss, "J123456")["color"] == 0xE74C3C + end + + test "a kill is green" do + kill = Factory.build(:killmail) + assert EmbedFormatter.format_kill(kill, @kill, "J123456")["color"] == 0x2ECC71 + end + + test "an uninvolved kill is coloured by ISK tier" do + tiers = [ + {5_000_000_000, 0xFF0000}, + {9_999_999_999, 0xFF0000}, + {4_999_999_999, 0xFF6600}, + {1_000_000_000, 0xFF6600}, + {999_999_999, 0xFFFF00}, + {100_000_000, 0xFFFF00}, + {99_999_999, 0x00FF00}, + {10_000_000, 0x00FF00}, + {9_999_999, 0x808080}, + {0, 0x808080} + ] + + Enum.each(tiers, fn {value, expected} -> + kill = Factory.build(:killmail, %{"total_value" => value}) + actual = EmbedFormatter.format_kill(kill, @bystander, "J123456")["color"] + + assert actual == expected, + "total_value #{value} coloured #{inspect(actual, base: :hex)}, " <> + "expected #{inspect(expected, base: :hex)}" + end) + end + + test "an uninvolved kill with no value falls to the default grey" do + kill = Factory.build(:killmail, %{"total_value" => nil}) + assert EmbedFormatter.format_kill(kill, @bystander, "J123456")["color"] == 0x808080 + end + + test "the loss colour and the kill colour do not depend on ISK value" do + cheap = Factory.build(:killmail, %{"total_value" => 1}) + rich = Factory.build(:killmail, %{"total_value" => 9_000_000_000}) + + assert EmbedFormatter.format_kill(cheap, @loss, "J")["color"] == + EmbedFormatter.format_kill(rich, @loss, "J")["color"] + + assert EmbedFormatter.format_kill(cheap, @kill, "J")["color"] == + EmbedFormatter.format_kill(rich, @kill, "J")["color"] + end + end + + describe "format_isk/1" do + test "handles ISK formatting boundary values correctly" do + test_cases = [ + {0, "0 ISK"}, + {999, "999 ISK"}, + {1_000, "1.0K ISK"}, + {999_999, "1.0M ISK"}, + {1_000_000, "1.0M ISK"}, + {999_999_999, "1.0B ISK"}, + {1_500_000_000, "1.5B ISK"}, + {nil, nil} + ] + + Enum.each(test_cases, fn {value, expected} -> + actual = EmbedFormatter.format_isk(value) + + assert actual == expected, + "format_isk(#{inspect(value)}) returned #{inspect(actual)}, expected #{inspect(expected)}" + end) + end + + test "handles trillion-ISK values correctly (supercapital/structure kills)" do + test_cases = [ + {999_999_999_999, "1.0T ISK"}, + {1_000_000_000_000, "1.0T ISK"}, + {5_000_000_000_000, "5.0T ISK"} + ] + + Enum.each(test_cases, fn {value, expected} -> + actual = EmbedFormatter.format_isk(value) + + assert actual == expected, + "format_isk(#{inspect(value)}) returned #{inspect(actual)}, expected #{inspect(expected)}" + end) + end + + test "clamps at trillion unit (does not underreport above 1 quadrillion)" do + test_cases = [ + {100_000_000_000_000, "100.0T ISK"}, + {999_000_000_000_000, "999.0T ISK"}, + {1_000_000_000_000_000, "1000.0T ISK"}, + {999_999_999_999_999, "1000.0T ISK"} + ] + + Enum.each(test_cases, fn {value, expected} -> + actual = EmbedFormatter.format_isk(value) + + assert actual == expected, + "format_isk(#{inspect(value)}) returned #{inspect(actual)}, expected #{inspect(expected)}" + end) + end + end + + describe "author line" do + test "a loss is labelled Loss and carries the victim's corp logo" do + kill = Factory.build(:killmail, %{"victim_corp_id" => 98_000_001}) + author = EmbedFormatter.format_kill(kill, @loss, "J123456")["author"] + + assert author["name"] == "Loss" + assert author["icon_url"] == "https://images.evetech.net/corporations/98000001/logo?size=64" + end + + test "a kill is labelled Kill and carries the final-blow pilot's corp logo" do + kill = Factory.build(:killmail, %{"final_blow_corp_id" => 98_000_002}) + author = EmbedFormatter.format_kill(kill, @kill, "J123456")["author"] + + assert author["name"] == "Kill" + assert author["icon_url"] == "https://images.evetech.net/corporations/98000002/logo?size=64" + end + + test "the author line is omitted entirely when not involved" do + kill = Factory.build(:killmail) + embed = EmbedFormatter.format_kill(kill, @bystander, "J123456") + + refute Map.has_key?(embed, "author") + end + + test "the label survives a missing corp id, without a logo" do + kill = Factory.build(:killmail, %{"victim_corp_id" => nil}) + author = EmbedFormatter.format_kill(kill, @loss, "J123456")["author"] + + assert author == %{"name" => "Loss"} + end + end + + describe "title and description prose" do + test "the title names the ship and the system it died in" do + kill = Factory.build(:killmail, %{"victim_ship_name" => "Vexor"}) + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + assert embed["title"] == "Vexor destroyed in Home" + end + + test "the title survives a missing ship name and a missing system name" do + kill = Factory.build(:killmail, %{"victim_ship_name" => nil}) + embed = EmbedFormatter.format_kill(kill, @loss, nil) + + assert embed["title"] == "Unknown ship destroyed in Unknown system" + end + + test "the prose links every name to zKillboard" do + kill = + Factory.build(:killmail, %{ + "victim_char_id" => 90_000_001, + "victim_char_name" => "Test Victim", + "victim_corp_id" => 98_000_001, + "victim_corp_ticker" => "TSTC", + "victim_ship_name" => "Vexor", + "final_blow_char_id" => 90_000_002, + "final_blow_char_name" => "Test Attacker", + "final_blow_corp_id" => 98_000_002, + "final_blow_corp_ticker" => "ATKC", + "top_damage_char_id" => 90_000_003, + "top_damage_char_name" => "Top Gun", + "attacker_count" => 5 + }) + + assert EmbedFormatter.format_kill(kill, @loss, "Home")["description"] == + "**[Test Victim](https://zkillboard.com/character/90000001/)** " <> + "(**[TSTC](https://zkillboard.com/corporation/98000001/)**) " <> + "lost their **Vexor** to " <> + "**[Test Attacker](https://zkillboard.com/character/90000002/)** " <> + "(**[ATKC](https://zkillboard.com/corporation/98000002/)**), " <> + "top damage by **[Top Gun](https://zkillboard.com/character/90000003/)**, " <> + "and 3 others." + end + + test "top damage is not named when it is the final-blow pilot" do + kill = + Factory.build(:killmail, %{ + "final_blow_char_id" => 90_000_002, + "final_blow_char_name" => "Test Attacker", + "top_damage_char_id" => 90_000_002, + "top_damage_char_name" => "Test Attacker", + "attacker_count" => 3 + }) + + description = EmbedFormatter.format_kill(kill, @loss, "Home")["description"] + + refute description =~ "top damage" + assert description =~ "and 2 others." + end + + test "a solo kill omits the others clause" do + kill = + Factory.build(:killmail, %{ + "attacker_count" => 1, + "final_blow_char_id" => 90_000_002, + "final_blow_char_name" => "Test Attacker", + "top_damage_char_id" => 90_000_002, + "top_damage_char_name" => "Test Attacker" + }) + + description = EmbedFormatter.format_kill(kill, @loss, "Home")["description"] + + refute description =~ "others" + refute description =~ "other." + assert description =~ "lost their **Vexor** to **[Test Attacker]" + end + + test "a single unnamed extra attacker reads 'other', not 'others'" do + kill = + Factory.build(:killmail, %{ + "attacker_count" => 2, + "final_blow_char_id" => 90_000_002, + "final_blow_char_name" => "Test Attacker", + "top_damage_char_id" => 90_000_002, + "top_damage_char_name" => "Test Attacker" + }) + + assert EmbedFormatter.format_kill(kill, @loss, "Home")["description"] =~ "and 1 other." + end + + test "an NPC final blow renders as absent, not as a placeholder name" do + kill = + Factory.build(:killmail, %{ + "npc" => true, + "attacker_count" => 1, + "final_blow_char_id" => nil, + "final_blow_char_name" => nil, + "final_blow_corp_id" => nil, + "final_blow_corp_ticker" => nil, + "top_damage_char_id" => nil, + "top_damage_char_name" => nil + }) + + description = EmbedFormatter.format_kill(kill, @bystander, "Home")["description"] + + assert description == + "**[Test Victim](https://zkillboard.com/character/90000001/)** " <> + "(**[TSTC](https://zkillboard.com/corporation/98000001/)**) " <> + "lost their **Vexor**." + + refute description =~ "Unknown" + refute description =~ "nil" + end + + test "an NPC final blow with a named top-damage pilot still reports the others clause" do + kill = + Factory.build(:killmail, %{ + "npc" => true, + "attacker_count" => 3, + "final_blow_char_id" => nil, + "final_blow_char_name" => nil, + "final_blow_corp_id" => nil, + "final_blow_corp_ticker" => nil, + "top_damage_char_id" => 90_000_003, + "top_damage_char_name" => "Top Gun" + }) + + description = EmbedFormatter.format_kill(kill, @bystander, "Home")["description"] + + assert description =~ "top damage by **[Top Gun]" + assert description =~ "and 2 others." + end + + test "names render unlinked when the id is missing" do + kill = + Factory.build(:killmail, %{ + "victim_char_id" => nil, + "victim_corp_id" => nil, + "final_blow_char_id" => nil, + "final_blow_corp_id" => nil, + "top_damage_char_id" => nil, + "top_damage_char_name" => nil, + "attacker_count" => 1 + }) + + assert EmbedFormatter.format_kill(kill, @loss, "Home")["description"] == + "**Test Victim** (**TSTC**) lost their **Vexor** to **Test Attacker** (**ATKC**)." + end + + test "a missing victim name does not leak the word nil" do + kill = + Factory.build(:killmail, %{ + "victim_char_id" => nil, + "victim_char_name" => nil, + "victim_corp_ticker" => nil, + "top_damage_char_id" => nil, + "top_damage_char_name" => nil + }) + + description = EmbedFormatter.format_kill(kill, @loss, "Home")["description"] + + assert description =~ "**Unknown pilot** lost their **Vexor**" + refute description =~ "nil" + end + end + + describe "fields" do + test "carries exactly Value and When, both inline" do + kill = Factory.build(:killmail, %{"total_value" => 84_000_000}) + fields = EmbedFormatter.format_kill(kill, @loss, "Home")["fields"] + + assert Enum.map(fields, & &1["name"]) == ["Value", "When"] + assert Enum.all?(fields, & &1["inline"]) + end + + test "Value uses the existing ISK formatter" do + kill = Factory.build(:killmail, %{"total_value" => 84_000_000}) + fields = EmbedFormatter.format_kill(kill, @loss, "Home")["fields"] + + assert Enum.find(fields, &(&1["name"] == "Value"))["value"] == "84.0M ISK" + end + + test "When is a Discord relative timestamp derived from kill_time" do + kill = Factory.build(:killmail, %{"kill_time" => "2026-08-01T12:00:00Z"}) + fields = EmbedFormatter.format_kill(kill, @loss, "Home")["fields"] + + unix = DateTime.to_unix(~U[2026-08-01 12:00:00Z]) + assert Enum.find(fields, &(&1["name"] == "When"))["value"] == "" + end + + test "an unparseable kill_time drops the When field rather than rendering junk" do + kill = Factory.build(:killmail, %{"kill_time" => "not a timestamp"}) + fields = EmbedFormatter.format_kill(kill, @loss, "Home")["fields"] + + assert Enum.map(fields, & &1["name"]) == ["Value"] + end + + test "a nil total_value drops the Value field" do + kill = Factory.build(:killmail, %{"total_value" => nil}) + fields = EmbedFormatter.format_kill(kill, @loss, "Home")["fields"] + + assert Enum.map(fields, & &1["name"]) == ["When"] + end + + test "the system name is no longer a field — it is in the title" do + kill = Factory.build(:killmail) + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + refute Enum.any?(embed["fields"], &(&1["name"] == "System")) + assert embed["title"] =~ "Home" + end + end + + describe "thumbnail and footer" do + test "renders a 1024px ship render when the ship type id is present" do + kill = Factory.build(:killmail, %{"victim_ship_type_id" => 626}) + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + assert embed["thumbnail"]["url"] == + "https://images.evetech.net/types/626/render?size=1024" + end + + test "falls back to the character portrait when the ship type id is absent" do + kill = + Factory.build(:killmail, %{ + "victim_ship_type_id" => nil, + "victim_char_id" => 90_000_001 + }) + + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + assert embed["thumbnail"]["url"] == + "https://images.evetech.net/characters/90000001/portrait?size=1024" + end + + test "a string-typed victim_char_id still links the name and renders the portrait" do + kill = + Factory.build(:killmail, %{ + "victim_char_id" => "90000001", + "victim_ship_type_id" => nil + }) + + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + assert embed["description"] =~ + "**[Test Victim](https://zkillboard.com/character/90000001/)**" + + assert embed["thumbnail"]["url"] == + "https://images.evetech.net/characters/90000001/portrait?size=1024" + end + + test "omits the thumbnail when neither ship type nor character id is present" do + kill = + Factory.build(:killmail, %{ + "victim_ship_type_id" => nil, + "victim_char_id" => nil + }) + + refute Map.has_key?(EmbedFormatter.format_kill(kill, @loss, "Home"), "thumbnail") + end + + test "prefers the ship render even when a character id is also present" do + kill = + Factory.build(:killmail, %{ + "victim_ship_type_id" => 626, + "victim_char_id" => 90_000_001 + }) + + assert EmbedFormatter.format_kill(kill, @loss, "Home")["thumbnail"]["url"] =~ "/types/626/" + end + + test "the footer carries the killmail id" do + kill = Factory.build(:killmail, %{"killmail_id" => 12_345}) + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + assert embed["footer"] == %{"text" => "Killmail ID: 12345"} + assert embed["url"] == "https://zkillboard.com/kill/12345/" + end + + test "the corp ticker is no longer in the footer" do + kill = Factory.build(:killmail, %{"victim_corp_ticker" => "TSTC"}) + embed = EmbedFormatter.format_kill(kill, @loss, "Home") + + refute embed["footer"]["text"] =~ "TSTC" + assert embed["description"] =~ "TSTC" + end + + test "is JSON-encodable and never leaks the word nil" do + kill = + Factory.build(:killmail, %{ + "victim_char_name" => nil, + "victim_corp_name" => nil, + "victim_corp_ticker" => nil, + "victim_alliance_name" => nil, + "victim_ship_name" => nil, + "victim_ship_type_id" => nil, + "victim_char_id" => nil, + "final_blow_char_name" => nil, + "top_damage_char_name" => nil, + "total_value" => nil, + "kill_time" => nil + }) + + embed = EmbedFormatter.format_kill(kill, @bystander, nil) + + assert {:ok, json} = Jason.encode(embed) + refute json =~ "nil" + end + end + + describe "format_batch/2" do + defp entries(count, verdict \\ :not_involved) do + for _ <- 1..count, do: {Factory.build(:killmail), verdict} + end + + test "single message for 10 or fewer kills" do + assert [%{"embeds" => embeds}] = EmbedFormatter.format_batch(entries(10), "J123456") + assert length(embeds) == 10 + end + + test "chunks into messages of at most 10 embeds" do + messages = EmbedFormatter.format_batch(entries(25), "J123456") + + assert length(messages) == 3 + assert Enum.all?(messages, &(length(&1["embeds"]) <= 10)) + assert messages |> Enum.map(&length(&1["embeds"])) |> Enum.sum() == 25 + end + + test "caps at 30 kills and notes the overflow" do + messages = EmbedFormatter.format_batch(entries(42), "J123456") + + assert messages |> Enum.map(&length(&1["embeds"])) |> Enum.sum() == 30 + assert List.last(messages)["content"] =~ "12 more" + end + + test "exactly 30 kills has no overflow notation" do + messages = EmbedFormatter.format_batch(entries(30), "J123456") + + assert messages |> Enum.map(&length(&1["embeds"])) |> Enum.sum() == 30 + refute Map.has_key?(List.last(messages), "content") + end + + test "returns empty list for no kills" do + assert EmbedFormatter.format_batch([], "J123456") == [] + end + + test "each kill is coloured by its own verdict within one batch" do + batch = [ + {Factory.build(:killmail), {:involved, :victim}}, + {Factory.build(:killmail), {:involved, :attacker}}, + {Factory.build(:killmail, %{"total_value" => 1_000}), :not_involved} + ] + + [%{"embeds" => embeds}] = EmbedFormatter.format_batch(batch, "J123456") + + assert Enum.map(embeds, & &1["color"]) == [0xE74C3C, 0x2ECC71, 0x808080] + end + + test "every embed in a batch carries the same system name" do + [%{"embeds" => embeds}] = EmbedFormatter.format_batch(entries(3), "Home") + + assert Enum.all?(embeds, &(&1["title"] =~ "destroyed in Home")) + end + + test "max_kills_per_event/0 is still 30" do + assert EmbedFormatter.max_kills_per_event() == 30 + end + + # Discord rejects an over-long embed with a 400, which the worker records as + # a delivery failure — so without these bounds a single long map-local + # system name would burn through @max_consecutive_failures and auto-disable + # the destination. `custom_name`/`temporary_name` carry no length + # constraint on MapSystem, so this is reachable from ordinary user input. + test "a system name longer than the title limit is truncated, not passed through" do + long_name = String.duplicate("あ", 400) + + [%{"embeds" => [embed]}] = EmbedFormatter.format_batch(entries(1), long_name) + + assert String.length(embed["title"]) == 256 + assert String.ends_with?(embed["title"], "…") + end + + test "a system name at exactly the title limit is left alone" do + # "X destroyed in " + name == exactly 256 graphemes. + {kill, verdict} = hd(entries(1)) + ship = kill["victim_ship_name"] + name = String.duplicate("a", 256 - String.length("#{ship} destroyed in ")) + + [%{"embeds" => [embed]}] = EmbedFormatter.format_batch([{kill, verdict}], name) + + assert String.length(embed["title"]) == 256 + refute String.ends_with?(embed["title"], "…") + assert embed["title"] =~ name + end + + test "a batch is split so no message exceeds the 6000-character text total" do + # Long pilot names, not a long system name: once the title is bounded at + # 256 the ten-embed message tops out well under 6000, so the description + # is the only way left to breach the total. ~2400 characters each means + # three embeds already exceed it. + long = String.duplicate("n", 2_400) + + batch = + for _ <- 1..12 do + {Factory.build(:killmail, %{"victim_char_name" => long}), :not_involved} + end + + messages = EmbedFormatter.format_batch(batch, "J123456") + + for %{"embeds" => embeds} <- messages do + assert embed_text_total(embeds) <= 6000, + "message carried #{embed_text_total(embeds)} characters of embed text" + + assert length(embeds) <= 10 + end + + # Every kill still delivered, just spread across more messages than the + # ten-embeds-per-message bound alone would produce. + assert messages |> Enum.map(&length(&1["embeds"])) |> Enum.sum() == 12 + assert length(messages) > 2 + end + + test "a realistic batch is still bounded by the embed count, not the text total" do + messages = EmbedFormatter.format_batch(entries(30), String.duplicate("b", 240)) + + assert Enum.all?(messages, &(embed_text_total(&1["embeds"]) <= 6000)) + assert messages |> Enum.map(&length(&1["embeds"])) |> Enum.sum() == 30 + assert length(messages) == 3 + end + + defp embed_text_total(embeds) do + Enum.reduce(embeds, 0, fn e, acc -> + fields = + e + |> Map.get("fields", []) + |> Enum.map(&(String.length(&1["name"]) + String.length(&1["value"]))) + |> Enum.sum() + + acc + String.length(e["title"] || "") + String.length(e["description"] || "") + + String.length(get_in(e, ["footer", "text"]) || "") + + String.length(get_in(e, ["author", "name"]) || "") + fields + end) + end + end +end diff --git a/test/unit/external_events/discord/matcher_involvement_test.exs b/test/unit/external_events/discord/matcher_involvement_test.exs new file mode 100644 index 000000000..b242f2160 --- /dev/null +++ b/test/unit/external_events/discord/matcher_involvement_test.exs @@ -0,0 +1,197 @@ +defmodule WandererApp.ExternalEvents.Discord.MatcherInvolvementTest do + # `involvement/3` itself is pure: no database, no cache, no application env. + # This file is `async: false` for one reason only — two tests below assert + # on a `Logger.debug` message, and `config/test.exs` pins the *primary* + # Logger level to :warning. `capture_log`'s own `:level` option cannot + # override that (per its docs: "this setting does not override the overall + # Logger.level/0 value"), and `Logger.put_process_level/2` does not either: + # `:logger.allow/2` caches its verdict per *module* in a `persistent_term`, + # so it has no way to vary by calling process. The only lever that actually + # works is the global primary level, so we flip it for the duration of the + # affected tests and restore it in `on_exit` — which requires this file to + # not run concurrently with other async suites. + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + alias WandererApp.ExternalEvents.Discord.Matcher + + setup do + previous_level = Logger.level() + Logger.configure(level: :debug) + on_exit(fn -> Logger.configure(level: previous_level) end) + :ok + end + + @tracked MapSet.new([1001, 1002]) + @focus [500_001] + + # A nested-format kill: Task 5 guarantees the attacker keys are present, even + # when the lists are empty. + defp nested(overrides) do + Map.merge( + %{ + "killmail_id" => 900_001, + "solar_system_id" => 31_000_005, + "victim_char_id" => 7000, + "victim_corp_id" => 700_000, + "attacker_char_ids" => [], + "attacker_corp_ids" => [] + }, + overrides + ) + end + + # A flat-format kill: the attacker keys are ABSENT, not empty. + defp flat(overrides) do + Map.merge( + %{ + "killmail_id" => 900_002, + "solar_system_id" => 31_000_005, + "victim_char_id" => 7000, + "victim_corp_id" => 700_000 + }, + overrides + ) + end + + test "a tracked victim character is a loss" do + kill = nested(%{"victim_char_id" => 1001}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :victim} + end + + test "a victim in a focused corporation is a loss" do + kill = nested(%{"victim_corp_id" => 500_001}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :victim} + end + + test "a tracked attacker character is a kill" do + kill = nested(%{"attacker_char_ids" => [4242, 1002]}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :attacker} + end + + test "an attacker in a focused corporation is a kill" do + kill = nested(%{"attacker_corp_ids" => [999_999, 500_001]}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :attacker} + end + + test "an untouched kill is not involved" do + kill = nested(%{"attacker_char_ids" => [4242], "attacker_corp_ids" => [999_999]}) + + assert Matcher.involvement(kill, @tracked, @focus) == :not_involved + end + + # Pins the ordering from spec section 3. Reversing the two blocks leaves every + # other test in this file green while turning every self-inflicted loss into a + # green "kill" embed in the channel. + test "the victim check wins when both sides are tracked" do + kill = nested(%{"victim_char_id" => 1001, "attacker_char_ids" => [1002]}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :victim} + end + + test "a flat payload with a tracked victim is still a loss" do + kill = flat(%{"victim_char_id" => 1001}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :victim} + end + + test "a flat payload with no victim match is not involved and logs the divergence" do + kill = flat(%{"killmail_id" => 900_777}) + + log = + capture_log([level: :debug], fn -> + assert Matcher.involvement(kill, @tracked, @focus) == :not_involved + end) + + assert log =~ "900777" + assert log =~ "attacker data absent" + end + + # THE case that distinguishes absent from empty. `Map.get(k, [])` in the + # implementation makes this test pass and the previous one fail; this one + # exists so nobody "fixes" that by silencing the log unconditionally. + test "present-but-empty attacker lists are not a divergence" do + kill = nested(%{"attacker_char_ids" => [], "attacker_corp_ids" => []}) + + log = + capture_log([level: :debug], fn -> + assert Matcher.involvement(kill, @tracked, @focus) == :not_involved + end) + + refute log =~ "attacker data absent" + end + + test "an empty focus list never matches" do + kill = nested(%{"victim_corp_id" => 500_001, "attacker_corp_ids" => [500_001]}) + + assert Matcher.involvement(kill, @tracked, []) == :not_involved + end + + # `victim_char_id` is a pass-through on the flat-payload branch (Task 5) and + # is NOT normalized by `collect_ids/2` — unlike the attacker id lists, it can + # arrive as a binary. The comparison site here must coerce it the same way + # `tracked_eve_ids/1` coerces `eve_id`, or a string-typed tracked victim + # silently fails to match. + test "a string-typed victim char id still produces a loss verdict" do + kill = flat(%{"victim_char_id" => "1001"}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :victim} + end + + test "a malformed victim char id string does not match and does not crash" do + kill = flat(%{"victim_char_id" => "12345abc"}) + + # `parse_eve_id/1` logs a warning for the non-numeric id; suppress it here + # so this test doesn't print a stray log line on every green run. + capture_log(fn -> + assert Matcher.involvement(kill, @tracked, @focus) == :not_involved + end) + end + + # `collect_ids/2` normalizes attacker ids to integers on the *nested* + # branch only (`add_attacker_identity_data/2`); the flat branch is a pure + # pass-through with no coercion. Nothing today enforces that a flat + # payload can't carry these keys as binaries, so the comparison site must + # coerce them too, or a string-typed tracked attacker silently misses. + test "a flat-shaped payload with string attacker ids still produces a kill verdict" do + kill = flat(%{"attacker_char_ids" => ["1002"], "attacker_corp_ids" => []}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :attacker} + end + + # NPC and structure kills legitimately have no character victim. Both + # attacker keys are absent on this fixture too, so this also fires the + # divergence log — wrapped to keep test output clean. + test "an absent victim char id does not match and does not crash" do + kill = flat(%{"victim_char_id" => nil, "victim_corp_id" => nil}) + + capture_log(fn -> + assert Matcher.involvement(kill, @tracked, @focus) == :not_involved + end) + end + + # Half-present attacker data: one key present, the other absent. The `||` + # inside `attacker_match?/3` must not be reached by the absent-vs-empty + # gate re-triggering on the missing half. + test "one attacker key present and the other absent still matches on the present key" do + kill = flat(%{"attacker_char_ids" => [1002]}) + + assert Matcher.involvement(kill, @tracked, @focus) == {:involved, :attacker} + end + + # `focus_corp_ids` is a caller contract, not runtime data — the guard makes + # a non-list argument a compile-visible `FunctionClauseError` rather than a + # `Protocol.UndefinedError` buried inside `Enum.member?/2`'s `in` expansion. + test "a non-list focus_corp_ids raises FunctionClauseError, not a protocol error" do + kill = nested(%{}) + + assert_raise FunctionClauseError, fn -> + Matcher.involvement(kill, @tracked, nil) + end + end +end diff --git a/test/unit/external_events/discord/router_test.exs b/test/unit/external_events/discord/router_test.exs new file mode 100644 index 000000000..60fa143e6 --- /dev/null +++ b/test/unit/external_events/discord/router_test.exs @@ -0,0 +1,194 @@ +defmodule WandererApp.ExternalEvents.Discord.RouterTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.{MapDiscordNotification, MapDiscordWebhook} + alias WandererApp.ExternalEvents.Discord.Router + alias WandererAppWeb.Factory + + @wh_system 31_000_005 + @ks_system 30_000_142 + + setup do + Cachex.put(:system_static_info_cache, @wh_system, %{ + solar_system_id: @wh_system, + solar_system_name: "J115405", + system_class: 3 + }) + + Cachex.put(:system_static_info_cache, @ks_system, %{ + solar_system_id: @ks_system, + solar_system_name: "Jita", + system_class: 0 + }) + + on_exit(fn -> + Cachex.del(:system_static_info_cache, @wh_system) + Cachex.del(:system_static_info_cache, @ks_system) + end) + + map = Factory.insert(:map, %{}) + + # `create` takes `webhook_url` as a required argument and seeds the `:system` + # child through `manage_relationship` in the same transaction, so the system + # webhook already exists here. Creating a second `:system` row would violate + # the (notification_id, role) identity. + {:ok, notification} = + MapDiscordNotification.create(%{ + map_id: map.id, + webhook_url: "https://discord.com/api/webhooks/1/sys" + }) + + notification = Ash.load!(notification, :webhooks) + system_wh = Enum.find(notification.webhooks, &(&1.role == :system)) + + %{map: map, notification: notification, system_wh: system_wh} + end + + defp with_webhooks(notification), do: Ash.load!(notification, :webhooks) + + defp add_character_webhook(notification) do + {:ok, wh} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/2/chr" + }) + + wh + end + + defp kill(system_id), do: %{"killmail_id" => 1, "solar_system_id" => system_id} + + test "rule 4: an uninvolved kill goes to the system webhook", %{ + notification: n, + system_wh: system_wh + } do + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: false}) + + assert {:ok, %{id: id}} = Router.route(kill(@ks_system), with_webhooks(n), :not_involved) + assert id == system_wh.id + end + + test "rule 3: an involved kill goes to the character webhook", %{notification: n} do + character_wh = add_character_webhook(n) + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: false}) + + assert {:ok, %{id: id}} = + Router.route(kill(@ks_system), with_webhooks(n), {:involved, :victim}) + + assert id == character_wh.id + end + + # The compatibility guarantee: every existing single-webhook config keeps + # working untouched. + test "rule 3 falls back to the system webhook when no character row exists", %{ + notification: n, + system_wh: system_wh + } do + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: false}) + + assert {:ok, %{id: id}} = + Router.route(kill(@ks_system), with_webhooks(n), {:involved, :attacker}) + + assert id == system_wh.id + end + + test "rule 1: an excluded system drops when not involved", %{notification: n} do + {:ok, n} = + MapDiscordNotification.update(n, %{wh_only: false, excluded_systems: [@ks_system]}) + + assert Router.route(kill(@ks_system), with_webhooks(n), :not_involved) == :drop + end + + test "rule 1 does not apply to an involved kill", %{notification: n} do + character_wh = add_character_webhook(n) + + {:ok, n} = + MapDiscordNotification.update(n, %{wh_only: false, excluded_systems: [@ks_system]}) + + assert {:ok, %{id: id}} = + Router.route(kill(@ks_system), with_webhooks(n), {:involved, :victim}) + + assert id == character_wh.id + end + + test "rule 2: wh_only drops known space when not involved", %{notification: n} do + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: true}) + + assert Router.route(kill(@ks_system), with_webhooks(n), :not_involved) == :drop + end + + test "rule 2 does not apply to an involved kill", %{notification: n} do + character_wh = add_character_webhook(n) + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: true}) + + assert {:ok, %{id: id}} = + Router.route(kill(@ks_system), with_webhooks(n), {:involved, :attacker}) + + assert id == character_wh.id + end + + test "wh_only still delivers wormhole kills", %{notification: n, system_wh: system_wh} do + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: true}) + + assert {:ok, %{id: id}} = Router.route(kill(@wh_system), with_webhooks(n), :not_involved) + assert id == system_wh.id + end + + # DROP, NOT REROUTE. Turning this into `{:ok, system_wh}` would post kills + # involving the user's own pilots into a channel they did not choose. + test "a disabled character webhook drops rather than rerouting", %{notification: n} do + character_wh = add_character_webhook(n) + {:ok, _} = MapDiscordWebhook.set_enabled(character_wh, %{enabled?: false}) + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: false}) + + assert Router.route(kill(@ks_system), with_webhooks(n), {:involved, :victim}) == :drop + end + + test "a disabled system webhook drops rather than rerouting", %{ + notification: n, + system_wh: system_wh + } do + _character_wh = add_character_webhook(n) + {:ok, _} = MapDiscordWebhook.set_enabled(system_wh, %{enabled?: false}) + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: false}) + + assert Router.route(kill(@ks_system), with_webhooks(n), :not_involved) == :drop + end + + # A disabled `:system` destination must not spill uninvolved kills into the + # character channel either. Both roles are present and enabled-state differs, + # so a reroute in either direction shows up here. + test "a disabled system webhook does not fall back to the character webhook", %{ + notification: n, + system_wh: system_wh + } do + character_id = add_character_webhook(n).id + {:ok, _} = MapDiscordWebhook.set_enabled(system_wh, %{enabled?: false}) + {:ok, n} = MapDiscordNotification.update(n, %{wh_only: false}) + + result = Router.route(kill(@ks_system), with_webhooks(n), :not_involved) + + assert result == :drop + refute match?({:ok, %{id: ^character_id}}, result) + end + + # `:webhooks` is a relationship, so a notification that reached the router + # without `Ash.load!/2` carries `%Ash.NotLoaded{}` here. Reading `.role` off + # that would raise on the dispatch path; the `is_list` guard in `webhook/2` + # turns it into "no destination" and drops, which is the conservative + # direction. Nothing else covers that clause — without this test, deleting + # the guard leaves the whole suite green. + test "an unloaded :webhooks relationship drops instead of raising", %{notification: n} do + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + + # Re-read rather than reusing the update's return value: `update/2` carries + # the loaded relationship through, and this test is about the record a + # caller gets when it never loaded one. + {:ok, unloaded} = MapDiscordNotification.by_id(n.id) + assert %Ash.NotLoaded{} = unloaded.webhooks + + assert Router.route(kill(@ks_system), unloaded, :not_involved) == :drop + assert Router.route(kill(@wh_system), unloaded, {:involved, :kill}) == :drop + end +end diff --git a/test/unit/external_events/discord/system_name_test.exs b/test/unit/external_events/discord/system_name_test.exs new file mode 100644 index 000000000..93edfc315 --- /dev/null +++ b/test/unit/external_events/discord/system_name_test.exs @@ -0,0 +1,173 @@ +defmodule WandererApp.ExternalEvents.Discord.SystemNameTest do + # `async: false` is mandatory: this file seeds `:system_static_info_cache`, + # a global Cachex table shared with every other test file, and it writes to + # the database. + use WandererApp.DataCase, async: false + + import Ecto.Query + + alias WandererApp.ExternalEvents.Discord.SystemName + alias WandererApp.Repo + alias WandererAppWeb.Factory + + # Real EVE ids: a J-space system and Jita. + @wh_system 31_000_005 + @ks_system 30_000_142 + + setup do + seed_static_info() + map = Factory.insert(:map, %{}) + %{map: map} + end + + # `map_solar_systems` is static import data and is NOT populated by `mix test` + # on a clean database, so the canonical name has to come from the cache. + # This mirrors `discord_dispatcher_test.exs:61-80`; the `on_exit` cleanup is + # required because the table is global. + defp seed_static_info do + Cachex.put(:system_static_info_cache, @wh_system, %{ + solar_system_id: @wh_system, + solar_system_name: "J115405", + system_class: 3 + }) + + Cachex.put(:system_static_info_cache, @ks_system, %{ + solar_system_id: @ks_system, + solar_system_name: "Jita", + system_class: 0 + }) + + on_exit(fn -> + Cachex.del(:system_static_info_cache, @wh_system) + Cachex.del(:system_static_info_cache, @ks_system) + end) + + :ok + end + + describe "the privacy constraint" do + # This test is named for the constraint on purpose. The asymmetry it locks + # in looks like an inconsistency and will invite a "fix"; the reason lives + # in the SystemName moduledoc. See the design doc §7. + test "map-local system names never reach the character webhook", %{map: map} do + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @wh_system, + name: "J115405", + temporary_name: "HOME" + }) + + assert SystemName.display_name(map.id, @wh_system, :character) == "J115405" + assert SystemName.display_name(map.id, @wh_system, :system) == "HOME" + end + + test "a custom_name is equally confined to the system webhook", %{map: map} do + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @wh_system, + name: "J115405", + custom_name: "Staging" + }) + + assert SystemName.display_name(map.id, @wh_system, :character) == "J115405" + assert SystemName.display_name(map.id, @wh_system, :system) == "Staging" + end + end + + describe "display_name/3 resolution order" do + test "temporary_name wins over custom_name on the system webhook", %{map: map} do + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @wh_system, + name: "J115405", + custom_name: "Staging", + temporary_name: "HOME" + }) + + assert SystemName.display_name(map.id, @wh_system, :system) == "HOME" + end + + test "falls through to the canonical name when neither is set", %{map: map} do + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @ks_system, + name: "Jita" + }) + + assert SystemName.display_name(map.id, @ks_system, :system) == "Jita" + assert SystemName.display_name(map.id, @ks_system, :character) == "Jita" + end + + # Ash's `:string` type defaults `allow_empty?` to false and casts `""` to + # `nil` on write, so writing through the Factory/Ash changeset can never + # persist an empty string in the first place — it would only ever exercise + # the `nil` branch, not `present("")`. Bypass Ash's write-side casting with + # a raw Ecto update straight against the table so the row genuinely holds + # `""`, then confirm the read path treats it as unset. + # + # No production write path can currently produce this state — every writer + # goes through an Ash action, and Ash normalizes `""` to `nil` at the + # boundary. This test is a drift guard, not a description of current + # behavior: it protects `present("")` against someone later flipping + # `allow_empty?: true`, or a raw-write importer/migration bypassing Ash. + test "an empty-string map-local name is treated as unset", %{map: map} do + system = + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @ks_system, + name: "Jita" + }) + + # Assert the row was actually hit. A `where` that matched nothing would + # leave the seeded system untouched, and the assertion below would then + # pass on the ordinary "no map-local name" path without ever exercising + # the empty-string case this test exists for. + assert {1, _} = + Repo.update_all( + from(s in "map_system_v1", where: s.id == type(^system.id, Ecto.UUID)), + set: [temporary_name: "", custom_name: ""] + ) + + assert SystemName.display_name(map.id, @ks_system, :system) == "Jita" + end + + test "a system absent from the map still resolves canonically", %{map: map} do + assert SystemName.display_name(map.id, @ks_system, :system) == "Jita" + assert SystemName.display_name(map.id, @ks_system, :character) == "Jita" + end + + test "returns nil when nothing can be resolved", %{map: map} do + unknown = 39_999_999 + + assert SystemName.display_name(map.id, unknown, :system) == nil + assert SystemName.display_name(map.id, unknown, :character) == nil + end + + test "a nil map_id does not crash the system role" do + assert SystemName.display_name(nil, @ks_system, :system) == "Jita" + end + + # A nil map_id is rejected by the action's `allow_nil?: false` and comes + # back as `{:error, _}`, which the `_ -> nil` clause absorbs whether or not + # the `is_binary(map_id)` guard is present — that path alone can't prove + # the guard does anything. Ash's `:string` argument casting auto-stringifies + # atoms (`cast_input/2` calls `to_string/1` for `is_atom` values), so an + # atom that happens to stringify to a *real* map id sails through the Ash + # call successfully instead of erroring. Without the guard, this atom would + # reach the Ash lookup, resolve the real system, and leak "HOME" onto a + # role that should only ever see canonical names for a malformed map_id. + test "a non-binary map_id that stringifies to a real map id is still rejected before the Ash lookup", + %{map: map} do + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @wh_system, + name: "J115405", + temporary_name: "HOME" + }) + + atom_map_id = String.to_atom(map.id) + + assert SystemName.display_name(atom_map_id, @wh_system, :system) == "J115405" + end + end +end diff --git a/test/unit/external_events/discord/worker_test.exs b/test/unit/external_events/discord/worker_test.exs new file mode 100644 index 000000000..b4a475f42 --- /dev/null +++ b/test/unit/external_events/discord/worker_test.exs @@ -0,0 +1,483 @@ +defmodule WandererApp.ExternalEvents.Discord.WorkerTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.{MapDiscordNotification, MapDiscordWebhook} + alias WandererApp.ExternalEvents.Discord.{HttpStub, Worker, WorkerSupervisor} + alias WandererAppWeb.Factory + + @system_url "https://discord.com/api/webhooks/123/tok" + @character_url "https://discord.com/api/webhooks/456/tok-char" + + setup do + HttpStub.start() + HttpStub.reset() + + start_supervised!(WorkerSupervisor) + + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: @system_url}) + + %{map: map, notification: notification, system: system_webhook(notification)} + end + + # `MapDiscordNotification.create/1` creates the parent and its `:system` child + # in one transaction (Task 1), so the system webhook always exists here. + defp system_webhook(notification) do + {:ok, webhooks} = MapDiscordWebhook.by_notification(notification.id) + Enum.find(webhooks, &(&1.role == :system)) + end + + defp character_webhook(notification, url \\ @character_url) do + {:ok, webhook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + + webhook + end + + defp message, do: %{"embeds" => [%{"title" => "test"}]} + + defp wait_for_requests(count, timeout \\ 2_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_wait(count, deadline) + end + + defp do_wait(count, deadline) do + if length(HttpStub.requests()) >= count do + HttpStub.requests() + else + if System.monotonic_time(:millisecond) > deadline do + flunk("expected #{count} requests, got #{length(HttpStub.requests())}") + else + Process.sleep(25) + do_wait(count, deadline) + end + end + end + + # Blocks until the worker has drained its mailbox up to this point. Cheaper + # and far less flaky than sleeping, now that every attempt is scheduled. + # Keyed by WEBHOOK id, not map id — that is the Registry key now. + # + # Raises rather than returning a sentinel when no worker is registered: as a + # no-op this stopped being a barrier at all, and the assertion that follows it + # would then race and pass for the wrong reason. + defp sync(webhook_id) do + case Registry.lookup(WorkerSupervisor.registry(), webhook_id) do + [{pid, _}] -> + :sys.get_state(pid) + + [] -> + flunk( + "no worker registered for webhook #{inspect(webhook_id)} — sync/1 cannot synchronize" + ) + end + end + + defp await_condition(fun, timeout \\ 2_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await_condition(fun, deadline) + end + + defp do_await_condition(fun, deadline) do + case fun.() do + {:ok, value} -> + value + + :retry -> + if System.monotonic_time(:millisecond) > deadline do + flunk("condition not met before deadline") + else + Process.sleep(25) + do_await_condition(fun, deadline) + end + end + end + + defp reload(webhook_id) do + {:ok, rec} = MapDiscordWebhook.by_id(webhook_id) + rec + end + + test "delivers a message to the configured url", %{system: w} do + WorkerSupervisor.deliver(w.id, [message()]) + + assert [{url, body}] = wait_for_requests(1) + assert url == @system_url + assert %{"embeds" => _} = body + end + + test "records success on the webhook", %{system: w} do + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + reloaded = + await_condition(fn -> + rec = reload(w.id) + if rec.last_delivery_at, do: {:ok, rec}, else: :retry + end) + + assert reloaded.consecutive_failures == 0 + end + + test "reloads the webhook, so a replaced url is not used", %{system: w} do + # Change the URL after capturing the (now stale) struct the caller holds. + {:ok, _} = + MapDiscordWebhook.update(w, %{webhook_url: "https://discord.com/api/webhooks/999/newtok"}) + + WorkerSupervisor.deliver(w.id, [message()]) + + assert [{url, _body}] = wait_for_requests(1) + assert url == "https://discord.com/api/webhooks/999/newtok" + end + + test "drops the event when the webhook was deleted while queued", %{system: w} do + id = w.id + :ok = MapDiscordWebhook.destroy(w) + + WorkerSupervisor.deliver(id, [message()]) + # Two syncs, not a sleep: the first flushes the deliver cast, the second the + # `:attempt` message that cast sends to itself. After both, the worker has + # decided whether to post. + sync(id) + sync(id) + + assert HttpStub.requests() == [] + end + + test "drops the event when the webhook was disabled while queued", %{system: w} do + {:ok, _} = MapDiscordWebhook.set_enabled(w, %{enabled?: false}) + + WorkerSupervisor.deliver(w.id, [message()]) + sync(w.id) + sync(w.id) + + assert HttpStub.requests() == [] + end + + test "sends multi-chunk events in order", %{system: w} do + msgs = [ + %{"embeds" => [%{"title" => "one"}]}, + %{"embeds" => [%{"title" => "two"}]}, + %{"embeds" => [%{"title" => "three"}]} + ] + + WorkerSupervisor.deliver(w.id, msgs) + requests = wait_for_requests(3) + + titles = + Enum.map(requests, fn {_url, body} -> + body["embeds"] |> hd() |> Map.get("title") + end) + + assert titles == ["one", "two", "three"] + end + + test "retries after a 429 honoring retry_after", %{system: w} do + HttpStub.set_responses([ + {:ok, 429, [{"retry-after", "0.05"}]}, + {:ok, 204, []} + ]) + + WorkerSupervisor.deliver(w.id, [message()]) + + assert length(wait_for_requests(2)) == 2 + end + + test "does not block its mailbox while waiting to retry", %{system: w} do + # A long retry-after must not stop the worker answering new casts: if the + # send path slept, this :sys.get_state would time out. + HttpStub.set_responses([{:ok, 429, [{"retry-after", "2"}]}, {:ok, 204, []}]) + + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + assert %{} = sync(w.id) + end + + test "drops the oldest event when the queue is full", %{system: w} do + # Hold the worker on a long retry so nothing drains while we overfill. + HttpStub.set_responses([{:ok, 429, [{"retry-after", "5"}]}]) + + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + for i <- 1..120 do + WorkerSupervisor.deliver(w.id, [%{"embeds" => [%{"title" => "q#{i}"}]}]) + end + + state = sync(w.id) + + assert state.queue_len == 100 + # Oldest were dropped, so the newest enqueued event survived. + assert state.queue |> :queue.to_list() |> List.last() == + [%{"embeds" => [%{"title" => "q120"}]}] + end + + test "does not retry a 403, but counts it as a failure", %{system: w} do + HttpStub.set_responses([{:ok, 403, []}]) + + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + reloaded = + await_condition(fn -> + rec = reload(w.id) + if rec.consecutive_failures == 1, do: {:ok, rec}, else: :retry + end) + + # One request only — 403 is permanent, no retry. + assert length(HttpStub.requests()) == 1 + # But it does NOT disable on its own; the 10-failure threshold governs. + assert reloaded.enabled? == true + assert reloaded.last_error =~ "403" + end + + test "a 401 increments failures without disabling", %{system: w} do + HttpStub.set_responses([{:ok, 401, []}]) + + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + reloaded = + await_condition(fn -> + rec = reload(w.id) + if rec.consecutive_failures == 1, do: {:ok, rec}, else: :retry + end) + + assert reloaded.enabled? == true + end + + test "disables the webhook on 404", %{system: w} do + HttpStub.set_responses([{:ok, 404, []}]) + + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + reloaded = + await_condition(fn -> + rec = reload(w.id) + if rec.enabled? == false, do: {:ok, rec}, else: :retry + end) + + assert reloaded.last_error =~ "404" + end + + test "disables after 10 consecutive failed events", %{system: w} do + HttpStub.set_responses(for _ <- 1..10, do: {:ok, 403, []}) + + for _ <- 1..10 do + WorkerSupervisor.deliver(w.id, [message()]) + end + + reloaded = + await_condition( + fn -> + rec = reload(w.id) + if rec.consecutive_failures >= 10, do: {:ok, rec}, else: :retry + end, + 5_000 + ) + + assert reloaded.enabled? == false + end + + test "a later failing chunk is not masked by an earlier success", %{system: w} do + HttpStub.set_responses([ + {:ok, 204, []}, + {:ok, 500, []}, + {:ok, 500, []}, + {:ok, 500, []}, + {:ok, 500, []}, + {:ok, 500, []} + ]) + + WorkerSupervisor.deliver(w.id, [message(), message()]) + + reloaded = + await_condition( + fn -> + rec = reload(w.id) + if rec.consecutive_failures == 1, do: {:ok, rec}, else: :retry + end, + 20_000 + ) + + assert reloaded.last_error != nil + # Per-event semantics: the successful first chunk must not stamp a delivery. + assert reloaded.last_delivery_at == nil + end + + test "stop_worker terminates a running worker", %{system: w} do + WorkerSupervisor.deliver(w.id, [message()]) + wait_for_requests(1) + + assert [{pid, _}] = Registry.lookup(WorkerSupervisor.registry(), w.id) + ref = Process.monitor(pid) + + assert :ok = WorkerSupervisor.stop_worker(w.id) + assert_receive {:DOWN, ^ref, :process, ^pid, _}, 1_000 + + # Registry cleans up its entry asynchronously when the owner dies, so the + # :DOWN can arrive before the key is released. Poll rather than sleep. + await_condition(fn -> + case Registry.lookup(WorkerSupervisor.registry(), w.id) do + [] -> {:ok, []} + _ -> :retry + end + end) + end + + test "stop_worker is a no-op when no worker is running", %{system: w} do + assert :ok = WorkerSupervisor.stop_worker(w.id) + end + + test "deliver returns an error instead of raising when the supervisor is down", %{system: w} do + # The kill-switch case: application.ex only starts WorkerSupervisor when + # webhooks are enabled, so the registry may not exist at all. deliver/2 and + # stop_worker/1 must be equally tolerant — a dispatcher call must not crash + # just because the feature is off. + stop_supervised!(WorkerSupervisor) + refute Process.whereis(WorkerSupervisor.registry()) + + assert {:error, :not_running} = WorkerSupervisor.deliver(w.id, [message()]) + assert :ok = WorkerSupervisor.stop_worker(w.id) + assert HttpStub.requests() == [] + end + + test "shuts down when idle", %{system: w} do + # Tiny idle timeout so this exercises the real shutdown path in ms. + pid = + start_supervised!( + {Worker, webhook_id: w.id, registry: WorkerSupervisor.registry(), idle_timeout: 50}, + restart: :temporary + ) + + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000 + end + + test "gives up on an event whose deadline has passed, without sending", %{system: w} do + # A negative deadline is already expired when the first attempt runs, so + # the event is abandoned before any request goes out. + pid = + start_supervised!( + {Worker, webhook_id: w.id, registry: WorkerSupervisor.registry(), event_deadline_ms: -1}, + restart: :temporary + ) + + Worker.enqueue(pid, [message()]) + + reloaded = + await_condition(fn -> + rec = reload(w.id) + if rec.consecutive_failures == 1, do: {:ok, rec}, else: :retry + end) + + assert HttpStub.requests() == [] + assert reloaded.last_error =~ "deadline" + assert reloaded.last_delivery_at == nil + end + + test "two webhooks on the same map deliver independently", %{ + notification: n, + system: sys + } do + char = character_webhook(n) + + WorkerSupervisor.deliver(sys.id, [message()]) + WorkerSupervisor.deliver(char.id, [message()]) + + wait_for_requests(2) + + assert length(HttpStub.requests_for(@system_url)) == 1 + assert length(HttpStub.requests_for(@character_url)) == 1 + + # Two distinct workers, not one shared queue. + assert [{sys_pid, _}] = Registry.lookup(WorkerSupervisor.registry(), sys.id) + assert [{char_pid, _}] = Registry.lookup(WorkerSupervisor.registry(), char.id) + assert sys_pid != char_pid + end + + test "a 404 on one webhook disables only that webhook", %{notification: n, system: sys} do + char = character_webhook(n) + HttpStub.set_responses_for(@character_url, [{:ok, 404, []}]) + + WorkerSupervisor.deliver(char.id, [message()]) + WorkerSupervisor.deliver(sys.id, [message()]) + + wait_for_requests(2) + + disabled = + await_condition(fn -> + rec = reload(char.id) + if rec.enabled? == false, do: {:ok, rec}, else: :retry + end) + + assert disabled.last_error =~ "404" + + # The system webhook is untouched: this is the failure the split exists for. + survivor = + await_condition(fn -> + rec = reload(sys.id) + if rec.last_delivery_at, do: {:ok, rec}, else: :retry + end) + + assert survivor.enabled? == true + assert survivor.consecutive_failures == 0 + end + + test "a 429 on one webhook does not delay the other", %{notification: n, system: sys} do + char = character_webhook(n) + # 2s is far longer than the 1s budget asserted below, and is clamped to + # @max_retry_after_ms (10s) so it stays a real wait. + HttpStub.set_responses_for(@character_url, [{:ok, 429, [{"retry-after", "2"}]}]) + + WorkerSupervisor.deliver(char.id, [message()]) + # Let the rate-limited worker take its 429 before the system kill is queued, + # so a shared queue would genuinely be blocked behind it. + await_condition(fn -> + if HttpStub.requests_for(@character_url) != [], do: {:ok, :sent}, else: :retry + end) + + started = System.monotonic_time(:millisecond) + WorkerSupervisor.deliver(sys.id, [message()]) + + await_condition(fn -> + if HttpStub.requests_for(@system_url) != [], do: {:ok, :sent}, else: :retry + end) + + elapsed = System.monotonic_time(:millisecond) - started + # 1s, not the 500ms this first used: `await_condition/2` polls every 25ms + # and a loaded CI runner adds scheduler and DB latency, so the tighter + # budget failed on jitter while the two webhooks were in fact independent. + # Still far below the 2s retry-after, so a genuinely shared queue fails. + assert elapsed < 1_000, "system kill waited #{elapsed}ms behind the rate-limited webhook" + + # And the character webhook is still mid-retry, not failed. + assert length(HttpStub.requests_for(@character_url)) == 1 + end + + test "stop_worker targets a single webhook", %{notification: n, system: sys} do + char = character_webhook(n) + + WorkerSupervisor.deliver(sys.id, [message()]) + WorkerSupervisor.deliver(char.id, [message()]) + wait_for_requests(2) + + assert [{char_pid, _}] = Registry.lookup(WorkerSupervisor.registry(), char.id) + ref = Process.monitor(char_pid) + + assert :ok = WorkerSupervisor.stop_worker(char.id) + assert_receive {:DOWN, ^ref, :process, ^char_pid, _}, 1_000 + + # The system worker is still registered and alive. + assert [{sys_pid, _}] = Registry.lookup(WorkerSupervisor.registry(), sys.id) + assert Process.alive?(sys_pid) + end +end diff --git a/test/unit/external_events/discord_dispatcher_test.exs b/test/unit/external_events/discord_dispatcher_test.exs new file mode 100644 index 000000000..0dadc50ad --- /dev/null +++ b/test/unit/external_events/discord_dispatcher_test.exs @@ -0,0 +1,1126 @@ +defmodule WandererApp.ExternalEvents.DiscordDispatcherTest do + # `async: false` is mandatory: `HttpStub` keeps its state in a single named + # Agent, and this file also mutates application env. + use WandererApp.DataCase, async: false + + alias WandererApp.Api.{MapDiscordNotification, MapDiscordWebhook} + alias WandererApp.ExternalEvents.{DiscordDispatcher, Event} + + alias WandererApp.ExternalEvents.Discord.{ + EmbedFormatter, + HttpStub, + Matcher, + WorkerSupervisor + } + + alias WandererAppWeb.Factory + + # A real wormhole system id (J-space) and a real known-space id (Jita). + @wh_system 31_000_005 + @ks_system 30_000_142 + + @system_url "https://discord.com/api/webhooks/123/tok" + @character_url "https://discord.com/api/webhooks/456/chr" + + setup do + # `wh_only` filtering resolves the system class through + # `CachedInfo.get_system_static_info/1`, which falls back to the + # `map_solar_systems` table. That table is static import data and is NOT + # populated by `mix test` on a clean database, so seed the cache directly — + # the same approach `WandererApp.MapTestHelpers` uses. + seed_static_info() + + # `config/test.exs:35` sets `external_events: [webhooks_enabled: false]`, and + # the dispatcher checks `Env.webhooks_enabled?/0` at call time. Without this + # override EVERY delivery assertion below would pass while sending nothing. + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :webhooks_enabled, true) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + + HttpStub.start() + HttpStub.reset() + start_supervised!(WorkerSupervisor) + start_supervised!(DiscordDispatcher) + + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: @system_url}) + + DiscordDispatcher.invalidate_cache(map.id) + + %{map: map, notification: notification, system: system_webhook(notification)} + end + + defp system_webhook(notification) do + {:ok, webhooks} = MapDiscordWebhook.by_notification(notification.id) + Enum.find(webhooks, &(&1.role == :system)) + end + + defp character_webhook(notification, url \\ @character_url) do + {:ok, wh} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + + wh + end + + # `tracked_eve_ids/1` reads a cache keyed by map (Task 6). Seed it directly: + # this file is about routing and batching, not about how the set is built. + # The cache name MUST match the Matcher's — it reads + # `:discord_notification_cache` under a namespaced key. Seeding a different + # cache here would leave the tracked set empty, every kill would take the + # `:not_involved` branch, and the routing tests would pass for the wrong + # reason while asserting nothing. + defp track(map_id, eve_ids) do + Cachex.put( + :discord_notification_cache, + "map:#{map_id}:tracked_eve_ids", + MapSet.new(eve_ids) + ) + + on_exit(fn -> Matcher.invalidate_tracked(map_id) end) + :ok + end + + defp killmail(id, overrides \\ %{}) do + Factory.build( + :killmail, + Map.merge( + %{ + "solar_system_id" => @wh_system, + "killmail_id" => id, + "victim_char_id" => 8000, + "victim_corp_id" => 800_000, + "attacker_char_ids" => [], + "attacker_corp_ids" => [] + }, + overrides + ) + ) + end + + # The formatter takes `{kill, verdict}` pairs. Tests that call it directly to + # derive an expected message count pair every kill with `:not_involved`, which + # only affects colour and author line, not the chunking they measure. + defp entries(kills), do: Enum.map(kills, &{&1, :not_involved}) + + # C3 for the J-space id, high-sec (class 0) for Jita, matching the shape + # `MapTestHelpers.default_test_systems/0` stores. + # + # `:system_static_info_cache` is a GLOBAL Cachex table, not sandboxed per test, + # so these entries must be removed again: `CommonAPIControllerTest` inserts its + # own Jita row and reads it back through this same cache, and a partial entry + # left behind here makes it fail on a missing `region_id`. + defp seed_static_info do + Cachex.put(:system_static_info_cache, @wh_system, %{ + solar_system_id: @wh_system, + solar_system_name: "J115405", + system_class: 3 + }) + + Cachex.put(:system_static_info_cache, @ks_system, %{ + solar_system_id: @ks_system, + solar_system_name: "Jita", + system_class: 0 + }) + + on_exit(fn -> + Cachex.del(:system_static_info_cache, @wh_system) + Cachex.del(:system_static_info_cache, @ks_system) + end) + + :ok + end + + defp disable_gate do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :webhooks_enabled, false) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + end + + defp kill_event(payload), do: %Event{map_id: nil, type: :map_kill, payload: payload} + + # Derived from the dispatcher rather than spelled out here. A hardcoded key + # would make every `refute marked?(...)` below pass vacuously if the key + # format ever changed — the assertion that matters most in these tests. + defp marked?(map_id, killmail_id) do + Cachex.exists?( + DiscordDispatcher.dedup_cache(), + DiscordDispatcher.dedup_key(map_id, killmail_id) + ) == {:ok, true} + end + + # Dispatch is a cast and delivery is a second async hop, so tests synchronize + # rather than guess: drain the dispatcher's mailbox, then the worker's. + # Keyed by WEBHOOK id — that is the Registry key since Task 3. + defp settle(webhook_id) do + :sys.get_state(DiscordDispatcher) + + case Registry.lookup(WorkerSupervisor.registry(), webhook_id) do + [{pid, _}] -> :sys.get_state(pid) + [] -> :no_worker + end + end + + # Asserting "nothing was delivered" needs more than `settle/1`: the HTTP call + # itself runs in a `Task.Supervisor.async_nolink` task, so a request can still + # be in flight when the worker's mailbox is drained. Wait until the worker is + # genuinely idle (no queued event, none in progress) before asserting, or the + # assertion passes for the wrong reason. Mutating the seeded system class + # confirms this: without the wait, marking Jita as wormhole space still leaves + # "skips non-wormhole systems" green. + # + # `webhook_id` may be nil (a map with no configuration at all), in which case + # there is no worker to wait on and the HTTP assertion is the whole check. + defp refute_delivery(webhook_id, timeout \\ 2_000) do + if webhook_id do + settle(webhook_id) + await_worker_idle(webhook_id, System.monotonic_time(:millisecond) + timeout) + else + :sys.get_state(DiscordDispatcher) + end + + assert HttpStub.requests() == [] + end + + defp await_worker_idle(webhook_id, deadline) do + case Registry.lookup(WorkerSupervisor.registry(), webhook_id) do + [] -> + :no_worker + + [{pid, _}] -> + state = :sys.get_state(pid) + + cond do + state.current == nil and state.queue_len == 0 -> + :idle + + System.monotonic_time(:millisecond) >= deadline -> + :timeout + + true -> + Process.sleep(25) + await_worker_idle(webhook_id, deadline) + end + end + end + + defp wait_for_requests(count, timeout \\ 2_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_wait(count, deadline) + end + + defp do_wait(count, deadline) do + cond do + length(HttpStub.requests()) >= count -> + HttpStub.requests() + + System.monotonic_time(:millisecond) > deadline -> + flunk("expected #{count} requests, got #{length(HttpStub.requests())}") + + true -> + Process.sleep(25) + do_wait(count, deadline) + end + end + + test "sends nothing when the global webhook gate is off", %{map: map, system: w} do + # Covers the gate itself rather than assuming it. This is the failure mode + # that would otherwise make every test in this file green but meaningless. + disable_gate() + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + test "delivers a wormhole kill", %{map: map, system: w} do + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{url, _body}] = wait_for_requests(1) + assert url == @system_url + end + + test "ignores kill_count events", %{map: map, system: w} do + event = kill_event(Factory.build(:kill_count_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + test "skips non-wormhole systems when wh_only is set", %{map: map, system: w} do + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @ks_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + test "delivers known-space kills when wh_only is off", %{map: map, notification: n, system: w} do + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @ks_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert length(wait_for_requests(1)) == 1 + end + + test "skips excluded systems", %{map: map, notification: n, system: w} do + {:ok, _} = MapDiscordNotification.update(n, %{excluded_systems: [@wh_system]}) + DiscordDispatcher.invalidate_cache(map.id) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + test "skips when the notification is disabled", %{map: map, notification: n, system: w} do + {:ok, _} = MapDiscordNotification.update(n, %{enabled?: false}) + DiscordDispatcher.invalidate_cache(map.id) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + # New in Task 4: enablement now lives on BOTH rows. The notification gates the + # whole map's policy; the webhook gates one destination. A webhook disabled by + # ten consecutive failures must drop here, before the dedup mark is burned — + # the worker would drop it too, but only after the kill was marked attempted. + test "skips when the system webhook is disabled", %{map: map, system: w} do + {:ok, _} = MapDiscordWebhook.set_enabled(w, %{enabled?: false}) + DiscordDispatcher.invalidate_cache(map.id) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + test "no-ops for a map with no configuration" do + other_map = Factory.insert(:map, %{}) + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(other_map.id, event) + + refute_delivery(nil) + end + + # A notification whose webhooks were all destroyed is a no-op, not a crash: + # `do_dispatch/2` must fall through its `with` rather than raise on an empty + # webhook list. + test "no-ops for a notification with no webhooks", %{map: map, system: w} do + :ok = MapDiscordWebhook.destroy(w) + DiscordDispatcher.invalidate_cache(map.id) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + assert Process.alive?(Process.whereis(DiscordDispatcher)) + end + + test "deduplicates a replayed killmail", %{map: map, system: w} do + kill = Factory.build(:killmail, %{solar_system_id: @wh_system, killmail_id: 777_777}) + payload = Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [kill]}) + + DiscordDispatcher.dispatch_event(map.id, kill_event(payload)) + settle(w.id) + wait_for_requests(1) + + DiscordDispatcher.dispatch_event(map.id, kill_event(payload)) + settle(w.id) + + assert length(HttpStub.requests()) == 1 + end + + test "delivers only the new kills in a partially-replayed batch", %{map: map, system: w} do + old = Factory.build(:killmail, %{solar_system_id: @wh_system, killmail_id: 111}) + new = Factory.build(:killmail, %{solar_system_id: @wh_system, killmail_id: 222}) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [old]})) + ) + + settle(w.id) + wait_for_requests(1) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [old, new]}) + ) + ) + + settle(w.id) + + assert [{_, _}, {_, second_body}] = wait_for_requests(2) + assert length(second_body["embeds"]) == 1 + end + + test "ignores non-kill event types", %{map: map, system: w} do + event = %Event{map_id: map.id, type: :add_system, payload: %{}} + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + end + + # Guards the carry-forward constraint: WorkerSupervisor.deliver/2 answers + # {:error, :not_running} when the worker tree is down. The dispatcher must + # neither crash nor treat that as delivered, and — since nothing was enqueued + # — must release the dedup marks so the kill can still be sent later. + test "survives the worker tree being down and does not burn the dedup mark", %{ + map: map, + system: w + } do + kill = Factory.build(:killmail, %{solar_system_id: @wh_system, killmail_id: 999_111}) + payload = Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [kill]}) + + :ok = stop_supervised(WorkerSupervisor) + + DiscordDispatcher.dispatch_event(map.id, kill_event(payload)) + :sys.get_state(DiscordDispatcher) + + assert HttpStub.requests() == [] + assert Process.alive?(Process.whereis(DiscordDispatcher)) + + start_supervised!(WorkerSupervisor) + + DiscordDispatcher.dispatch_event(map.id, kill_event(payload)) + settle(w.id) + + assert length(wait_for_requests(1)) == 1 + end + + # Pins the dedup key as PER-MAP. Deleting `map_id` from `dedup_key/2` makes + # every other test still pass, while the second map would silently stop + # receiving any kill the first one already reported. + test "dedup is per-map: two maps both receive the same killmail", %{ + map: map_a, + system: w_a + } do + map_b = Factory.insert(:map, %{}) + url_b = "https://discord.com/api/webhooks/456/tok-b" + + {:ok, notification_b} = + MapDiscordNotification.create(%{map_id: map_b.id, webhook_url: url_b}) + + w_b = system_webhook(notification_b) + DiscordDispatcher.invalidate_cache(map_b.id) + + kill = Factory.build(:killmail, %{solar_system_id: @wh_system, killmail_id: 555_555}) + payload = Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [kill]}) + + DiscordDispatcher.dispatch_event(map_a.id, kill_event(payload)) + settle(w_a.id) + wait_for_requests(1) + + DiscordDispatcher.dispatch_event(map_b.id, kill_event(payload)) + settle(w_b.id) + + requests = wait_for_requests(2) + assert length(requests) == 2 + + # Distinct webhook URLs prove both maps were served, not one map twice. + urls = requests |> Enum.map(&elem(&1, 0)) |> Enum.sort() + assert urls == Enum.sort([@system_url, url_b]) + end + + # Kills past the formatter's per-event cap are never rendered into a message, + # so they must not be marked attempted — otherwise they are burned for the + # full dedup TTL without ever being sent. + test "does not burn kills dropped by the formatter's per-event cap", %{map: map, system: w} do + cap = EmbedFormatter.max_kills_per_event() + + kills = + for i <- 1..(cap + 5) do + Factory.build(:killmail, %{solar_system_id: @wh_system, killmail_id: 600_000 + i}) + end + + overflow = Enum.drop(kills, cap) + assert length(overflow) == 5 + + # The capped event spans several chunks, and the worker deliberately spaces + # them. Derive how many messages to expect from the formatter itself rather + # than assuming the first `wait_for_requests/1` catches all of them. + first_batch_size = length(EmbedFormatter.format_batch(entries(kills), "X")) + assert first_batch_size > 1 + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: kills})) + ) + + settle(w.id) + first_batch = wait_for_requests(first_batch_size) + assert length(first_batch) == first_batch_size + + # The overflow kills arrive again on their own: they were never formatted, + # so they are still eligible and must be delivered now. + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: overflow})) + ) + + settle(w.id) + + later = wait_for_requests(first_batch_size + 1) + [{_url, body} | _] = Enum.drop(later, first_batch_size) + assert length(body["embeds"]) == 5 + end + + describe "per-destination routing" do + # The core assertion: one batch, three fates. + test "a mixed batch splits across destinations and drops the rest", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + track(map.id, [1001]) + + {:ok, _} = + MapDiscordNotification.update(n, %{wh_only: false, excluded_systems: [@ks_system]}) + + DiscordDispatcher.invalidate_cache(map.id) + + # Involved (tracked victim) in an EXCLUDED system: the carve-out applies, + # so it still goes to the character channel. + involved = killmail(700_001, %{"solar_system_id" => @ks_system, "victim_char_id" => 1001}) + + # Uninvolved, allowed system: system channel. + uninvolved = killmail(700_002, %{"solar_system_id" => @wh_system}) + + # Uninvolved, excluded system: dropped. + dropped = killmail(700_003, %{"solar_system_id" => @ks_system}) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{ + solar_system_id: @wh_system, + killmails: [involved, uninvolved, dropped] + }) + ) + ) + + settle(system_wh.id) + settle(character_wh.id) + + requests = wait_for_requests(2) + by_url = Enum.group_by(requests, &elem(&1, 0), &elem(&1, 1)) + + assert [system_body] = by_url[@system_url] + assert [character_body] = by_url[@character_url] + + assert length(system_body["embeds"]) == 1 + assert length(character_body["embeds"]) == 1 + + # Exactly two messages: the third kill went nowhere. This is a negative + # assertion, so `settle/1` alone is not enough — a third request could + # still be in flight in an async task. Wait until BOTH workers are + # genuinely idle, or the count passes for the wrong reason. + deadline = System.monotonic_time(:millisecond) + 2_000 + assert await_worker_idle(system_wh.id, deadline) in [:idle, :no_worker] + assert await_worker_idle(character_wh.id, deadline) in [:idle, :no_worker] + + assert length(HttpStub.requests()) == 2 + end + + # The exact bug the whole-partition rule prevents. If `deliver_to/5` passed + # the pre-truncated list to `format_batch/2`, the overflow line disappears + # and this fails with `contents == []`. + test "the overflow line counts kills beyond the per-destination cap", %{ + map: map, + notification: n, + system: system_wh + } do + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, []) + + cap = EmbedFormatter.max_kills_per_event() + kills = for i <- 1..(cap + 5), do: killmail(710_000 + i) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: kills})) + ) + + settle(system_wh.id) + + expected = length(EmbedFormatter.format_batch(entries(kills), "X")) + requests = wait_for_requests(expected) + + contents = + requests |> Enum.map(fn {_url, body} -> body["content"] end) |> Enum.reject(&is_nil/1) + + assert ["…and 5 more kills not shown."] == contents + end + + test "two destinations each get their own cap budget", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + track(map.id, [1001]) + + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + + cap = EmbedFormatter.max_kills_per_event() + + system_kills = for i <- 1..(cap + 5), do: killmail(720_000 + i) + + character_kills = + for i <- 1..(cap + 5), do: killmail(730_000 + i, %{"victim_char_id" => 1001}) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{ + solar_system_id: @wh_system, + killmails: system_kills ++ character_kills + }) + ) + ) + + settle(system_wh.id) + settle(character_wh.id) + + per_destination = length(EmbedFormatter.format_batch(entries(system_kills), "X")) + requests = wait_for_requests(per_destination * 2) + by_url = Enum.group_by(requests, &elem(&1, 0), &elem(&1, 1)) + + system_bodies = by_url[@system_url] + character_bodies = by_url[@character_url] + + # Each destination renders a FULL cap of kills. A shared budget would give + # one of them 30 and the other 0. + assert Enum.sum(Enum.map(system_bodies, &length(&1["embeds"]))) == cap + assert Enum.sum(Enum.map(character_bodies, &length(&1["embeds"]))) == cap + + # And each counts only its own overflow. + assert Enum.any?(system_bodies, &(&1["content"] == "…and 5 more kills not shown.")) + assert Enum.any?(character_bodies, &(&1["content"] == "…and 5 more kills not shown.")) + end + + # A kill the router drops belongs to no partition, so it is never marked. If + # it becomes routable later — the user removes the exclusion, or one of their + # pilots turns up in it — it must still be deliverable. + test "kills dropped by the router are not marked attempted", %{ + map: map, + notification: n, + system: system_wh + } do + # Bind the updated record: Ash diffs against the struct it is given, so a + # second update from the stale `n` would see `excluded_systems` already + # `[]` and change nothing. + {:ok, excluded} = + MapDiscordNotification.update(n, %{wh_only: false, excluded_systems: [@ks_system]}) + + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, []) + + kill = killmail(740_001, %{"solar_system_id" => @ks_system}) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @ks_system, killmails: [kill]})) + ) + + refute_delivery(system_wh.id) + + # Lift the exclusion; the same killmail must now be delivered. + {:ok, _} = MapDiscordNotification.update(excluded, %{excluded_systems: []}) + DiscordDispatcher.invalidate_cache(map.id) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @ks_system, killmails: [kill]})) + ) + + settle(system_wh.id) + + assert [{_url, body}] = wait_for_requests(1) + assert length(body["embeds"]) == 1 + end + + # DROP, NOT REROUTE. The Router unit test covers the decision; this covers + # the wiring end to end, because a reroute would show up here as a message + # on the system URL. + test "a disabled character webhook drops rather than rerouting", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + {:ok, _} = MapDiscordWebhook.set_enabled(character_wh, %{enabled?: false}) + + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, [1001]) + + involved = killmail(750_001, %{"victim_char_id" => 1001}) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [involved]}) + ) + ) + + # Nothing anywhere — in particular, nothing on the system webhook. + refute_delivery(system_wh.id) + end + + # The mirror image, with BOTH roles configured: a disabled `:system` + # destination must not spill its uninvolved kills into the character + # channel. + test "a disabled system webhook does not fall back to the character webhook", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + {:ok, _} = MapDiscordWebhook.set_enabled(system_wh, %{enabled?: false}) + + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, [1001]) + + uninvolved = killmail(755_001, %{"victim_char_id" => 4242}) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [uninvolved]}) + ) + ) + + refute_delivery(character_wh.id) + end + + # Partition results are independent. Stopping the worker tree makes BOTH + # partitions report `:not_running`, so both sets of marks must be released + # and both kills must still be deliverable on the replay. + test "not_running releases the marks of every failing partition", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, [1001]) + + system_kill = killmail(760_001) + character_kill = killmail(760_002, %{"victim_char_id" => 1001}) + + :ok = stop_supervised(WorkerSupervisor) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{ + solar_system_id: @wh_system, + killmails: [system_kill, character_kill] + }) + ) + ) + + :sys.get_state(DiscordDispatcher) + assert HttpStub.requests() == [] + assert Process.alive?(Process.whereis(DiscordDispatcher)) + + start_supervised!(WorkerSupervisor) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{ + solar_system_id: @wh_system, + killmails: [system_kill, character_kill] + }) + ) + ) + + settle(system_wh.id) + settle(character_wh.id) + + requests = wait_for_requests(2) + by_url = Enum.group_by(requests, &elem(&1, 0), &elem(&1, 1)) + + assert [system_body] = by_url[@system_url] + assert [character_body] = by_url[@character_url] + assert length(system_body["embeds"]) == 1 + assert length(character_body["embeds"]) == 1 + end + + test "telemetry is emitted per destination with the role", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, [1001]) + + test_pid = self() + handler_id = "discord-role-telemetry-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:wanderer_app, :discord_dispatcher, :dispatched], + fn _event, measurements, metadata, _config -> + send(test_pid, {:dispatched, metadata[:role], measurements[:count]}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{ + solar_system_id: @wh_system, + killmails: [killmail(770_001), killmail(770_002, %{"victim_char_id" => 1001})] + }) + ) + ) + + settle(system_wh.id) + settle(character_wh.id) + + assert_receive {:dispatched, :system, 1} + assert_receive {:dispatched, :character, 1} + end + + # The privacy boundary, end to end. The map-local name is visible on the + # system channel and MUST NOT appear on the character channel, which is + # commonly public. A caller passing `:system` where it meant `:character` + # is the leak path `SystemName` cannot defend against on its own. + test "map-local system names reach the system channel only", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + + {:ok, _} = + WandererApp.Api.MapSystem.create(%{ + map_id: map.id, + solar_system_id: @wh_system, + name: "J115405", + temporary_name: "HOME", + position_x: 0, + position_y: 0 + }) + + {:ok, _} = MapDiscordNotification.update(n, %{wh_only: false}) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, [1001]) + + DiscordDispatcher.dispatch_event( + map.id, + kill_event( + Factory.build(:kill_event, %{ + solar_system_id: @wh_system, + killmails: [killmail(780_001), killmail(780_002, %{"victim_char_id" => 1001})] + }) + ) + ) + + settle(system_wh.id) + settle(character_wh.id) + + by_url = wait_for_requests(2) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + + assert [system_body] = by_url[@system_url] + assert [character_body] = by_url[@character_url] + + assert hd(system_body["embeds"])["title"] =~ "HOME" + refute hd(character_body["embeds"])["title"] =~ "HOME" + assert hd(character_body["embeds"])["title"] =~ "J115405" + end + end + + test "send_test_message reports the global gate being off", %{system: w} do + disable_gate() + + assert {:error, :notifications_disabled} = DiscordDispatcher.send_test_message(w.id) + assert HttpStub.requests() == [] + end + + test "send_test_message goes through the worker", %{system: w} do + assert :ok = DiscordDispatcher.send_test_message(w.id) + + assert [{url, body}] = wait_for_requests(1) + assert url == @system_url + assert body["content"] =~ "test message" + end + + # `send_test_message/1` answers with THREE distinct atoms where it used to + # answer `:not_configured` for all of them. These four tests exist to keep + # them distinct: collapsing any one branch into another turns at least one of + # them red, which a single "some error came back" assertion would not. + test "send_test_message distinguishes an unknown webhook" do + assert {:error, :webhook_not_found} = + DiscordDispatcher.send_test_message(Ash.UUID.generate()) + end + + test "send_test_message distinguishes a saved but disabled webhook", %{system: w} do + {:ok, _} = MapDiscordWebhook.set_enabled(w, %{enabled?: false}) + + assert {:error, :webhook_disabled} = DiscordDispatcher.send_test_message(w.id) + assert HttpStub.requests() == [] + end + + test "send_test_message distinguishes a row that decrypts to no URL", %{system: w} do + blank_the_url!(w.id) + + assert {:error, :webhook_url_missing} = DiscordDispatcher.send_test_message(w.id) + assert HttpStub.requests() == [] + end + + # Clause order, pinned: "disabled" wins over "no URL". This is what the + # component used to decide for itself by checking its own assigns, and the + # copy a user sees depends on it — swap the two clauses and this goes red + # while the three tests above stay green. + test "send_test_message reports a disabled URL-less webhook as disabled", %{system: w} do + blank_the_url!(w.id) + {:ok, _} = MapDiscordWebhook.set_enabled(w, %{enabled?: false}) + + assert {:error, :webhook_disabled} = DiscordDispatcher.send_test_message(w.id) + end + + # `webhook_url` is `allow_nil? false` and validated on write, so a URL-less row + # cannot be created through Ash. It is still reachable in production through a + # hand-repaired row or a half-finished migration, so build it the only way the + # storage format allows: AshCloak stores `Base.encode64(encrypt(term_to_binary(value)))`, + # so encrypting `nil` yields a row that reads back with `webhook_url: nil`. + defp blank_the_url!(webhook_id) do + {:ok, ciphertext} = WandererApp.Vault.encrypt(:erlang.term_to_binary(nil)) + + {:ok, _} = + WandererApp.Repo.query( + "update map_discord_webhooks_v1 set encrypted_webhook_url = $1 where id = $2", + [Base.encode64(ciphertext), Ecto.UUID.dump!(webhook_id)] + ) + + # Fail loudly here rather than letting the caller's assertion pass for the + # wrong reason if the storage format ever changes. + {:ok, reread} = MapDiscordWebhook.by_id(webhook_id) + assert is_nil(reread.webhook_url) + :ok + end + + describe "maximum killmail age" do + setup do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :discord_max_killmail_age_seconds, 3600) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + :ok + end + + test "a stale killmail is not delivered", %{map: map, system: w} do + stale = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.to_iso8601() + + DiscordDispatcher.dispatch_event(map.id, kill_event(@wh_system, [kill(9_001, stale)])) + + refute_delivery(w.id) + end + + # The full round trip, now that the dispatcher formats and delivers again: + # the stale kill leaves no dedup mark, so the SAME killmail arriving later + # with a fresh timestamp is still delivered. This is the guard's actual job + # — suppress the replay, not the killmail. + test "a stale killmail is not marked, so a later fresh arrival still delivers", + %{map: map, system: w} do + stale = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.to_iso8601() + + DiscordDispatcher.dispatch_event(map.id, kill_event(@wh_system, [kill(9_002, stale)])) + + refute_delivery(w.id) + refute marked?(map.id, 9002) + + fresh = DateTime.utc_now() |> DateTime.to_iso8601() + DiscordDispatcher.dispatch_event(map.id, kill_event(@wh_system, [kill(9_002, fresh)])) + + settle(w.id) + + assert [{_url, body}] = wait_for_requests(1) + assert length(body["embeds"]) == 1 + assert hd(body["embeds"])["footer"]["text"] == "Killmail ID: 9002" + end + + # A mixed batch is where dropping the `kill_fresh?/3` call site is most + # easily missed: the fresh kill delivers either way, so the assertion that + # bites is the embed COUNT and the id it carries. + test "a mixed batch delivers only the fresh kill", %{map: map, system: w} do + stale = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.to_iso8601() + recent = DateTime.utc_now() |> DateTime.to_iso8601() + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(@wh_system, [kill(9_003, stale), kill(9_004, recent)]) + ) + + settle(w.id) + + assert [{_url, body}] = wait_for_requests(1) + assert length(body["embeds"]) == 1 + assert hd(body["embeds"])["footer"]["text"] == "Killmail ID: 9004" + + # And the stale one was never burned, unlike the delivered one. + assert marked?(map.id, 9004) + refute marked?(map.id, 9003) + end + + # The guard must run ONCE, upstream of partitioning — never inside a + # per-destination loop, and never after `mark_attempted/2`. With both roles + # configured, the stale kill in EACH partition must be filtered out and left + # unmarked while that partition's fresh kill still goes out. + test "the age guard runs before marking in every partition", %{ + map: map, + notification: n, + system: system_wh + } do + character_wh = character_webhook(n) + DiscordDispatcher.invalidate_cache(map.id) + track(map.id, [1001]) + + stale = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.to_iso8601() + recent = DateTime.utc_now() |> DateTime.to_iso8601() + + kills = [ + killmail(9_201, %{"kill_time" => stale}), + killmail(9_202, %{"kill_time" => recent}), + killmail(9_203, %{"kill_time" => stale, "victim_char_id" => 1001}), + killmail(9_204, %{"kill_time" => recent, "victim_char_id" => 1001}) + ] + + DiscordDispatcher.dispatch_event( + map.id, + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: kills})) + ) + + settle(system_wh.id) + settle(character_wh.id) + + by_url = wait_for_requests(2) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + + assert [system_body] = by_url[@system_url] + assert [character_body] = by_url[@character_url] + assert length(system_body["embeds"]) == 1 + assert length(character_body["embeds"]) == 1 + assert hd(system_body["embeds"])["footer"]["text"] == "Killmail ID: 9202" + assert hd(character_body["embeds"])["footer"]["text"] == "Killmail ID: 9204" + + refute marked?(map.id, 9201) + refute marked?(map.id, 9203) + end + + # Pins the fix for the exact regression the reviewer caught: `kill_fresh?/3` + # takes `max_age_seconds` as an argument rather than calling + # `Env.discord_max_killmail_age_seconds/0` internally, so `do_dispatch/2` + # resolves (and, on a misconfigured value, warns) ONCE per batch — not once + # per kill. Before that fix this test fails with 3 warnings, one per kill. + # + # All three kills are stale regardless of whether `0` or its validated + # fallback (3600) ends up in effect, so the whole batch is filtered before + # partitioning and nothing is formatted or delivered. + test "a misconfigured max age warns once per batch, not once per kill", %{map: map} do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :discord_max_killmail_age_seconds, 0) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + + stale = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.to_iso8601() + + kills = [kill(9_101, stale), kill(9_102, stale), kill(9_103, stale)] + + log = + capture_log(fn -> + DiscordDispatcher.dispatch_event(map.id, kill_event(@wh_system, kills)) + :sys.get_state(DiscordDispatcher) + end) + + warning_count = + log + |> String.split("\n") + |> Enum.count(&(&1 =~ "discord_max_killmail_age_seconds")) + + assert warning_count == 1 + end + end + + # Minimal killmail and event builders matching what `extract_kills/1` expects. + defp kill(id, kill_time) do + %{ + "killmail_id" => id, + "kill_time" => kill_time, + "solar_system_id" => @wh_system, + "victim_char_name" => "Pilot #{id}", + "victim_ship_name" => "Rifter" + } + end + + defp kill_event(system_id, killmails) do + %Event{ + type: :map_kill, + payload: %{ + "type" => :killmail_update, + "solar_system_id" => system_id, + "killmails" => killmails + } + } + end +end diff --git a/test/unit/external_events/discord_killmail_age_test.exs b/test/unit/external_events/discord_killmail_age_test.exs new file mode 100644 index 000000000..02920da4f --- /dev/null +++ b/test/unit/external_events/discord_killmail_age_test.exs @@ -0,0 +1,147 @@ +defmodule WandererApp.ExternalEvents.DiscordKillmailAgeTest do + # `async: false` is mandatory: this file mutates application env, which is + # global and would leak into any test running concurrently. + use WandererApp.DataCase, async: false + + alias WandererApp.Env + alias WandererApp.ExternalEvents.DiscordDispatcher + + # Mirrors discord_dispatcher_test.exs:26-34 — read the whole `:external_events` + # keyword list, put the one key back on top of it, and restore the original + # list wholesale in `on_exit` so unrelated keys (webhooks_enabled, + # webhook_timeout_ms) survive. + defp put_max_age(seconds) do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :discord_max_killmail_age_seconds, seconds) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + :ok + end + + describe "Env.discord_max_killmail_age_seconds/0" do + test "defaults to 3600 when the key is absent" do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.delete(original, :discord_max_killmail_age_seconds) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + + assert Env.discord_max_killmail_age_seconds() == 3600 + end + + # The regression this guards: an accessor that hardcodes its default and + # never reads config passes the test above and fails this one. + test "returns the configured value, not only the default" do + put_max_age(120) + + assert Env.discord_max_killmail_age_seconds() == 120 + end + + # `0` would otherwise silently drop every killmail (a kill that has already + # happened always has a non-negative age, and the guard keeps a kill only + # when `age <= max`), and a negative value is stricter still — it would keep + # only kills timestamped in the future. Both fail in the same direction, so + # both are treated the same way — a misconfiguration, not a valid setting — + # falling back to the default with a loud warning rather than being honoured. + test "falls back to the default and warns when configured as zero" do + put_max_age(0) + + log = + capture_log(fn -> + assert Env.discord_max_killmail_age_seconds() == 3600 + end) + + assert log =~ "discord_max_killmail_age_seconds" + end + + test "falls back to the default and warns when configured as negative" do + put_max_age(-60) + + log = + capture_log(fn -> + assert Env.discord_max_killmail_age_seconds() == 3600 + end) + + assert log =~ "discord_max_killmail_age_seconds" + end + + test "falls back to the default and warns when configured as a non-integer" do + put_max_age("not-a-number") + + log = + capture_log(fn -> + assert Env.discord_max_killmail_age_seconds() == 3600 + end) + + assert log =~ "discord_max_killmail_age_seconds" + end + end + + # A fixed reference instant, so these assertions never depend on wall clock. + @now ~U[2026-08-03 12:00:00Z] + + defp kill_at(iso8601), do: %{"killmail_id" => 1, "kill_time" => iso8601} + + describe "DiscordDispatcher.kill_fresh?/2" do + setup do + put_max_age(3600) + end + + test "a kill exactly at the boundary is allowed through" do + # 12:00:00 - 3600s = 11:00:00, age == max, inclusive. + assert DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T11:00:00Z"), @now) + end + + test "a kill one second inside the boundary is allowed through" do + assert DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T11:00:01Z"), @now) + end + + test "a kill one second outside the boundary is dropped" do + refute DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T10:59:59Z"), @now) + end + + test "a far-older kill is dropped" do + refute DiscordDispatcher.kill_fresh?(kill_at("2026-08-01T12:00:00Z"), @now) + end + + # A negative age must not be treated as a huge positive one by a sloppy + # `abs/1` or an argument-order slip in `DateTime.diff/3`. + test "a future-dated kill_time is allowed through" do + assert DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T12:05:00Z"), @now) + end + + test "an unparseable kill_time is allowed through (fail-open)" do + assert DiscordDispatcher.kill_fresh?(kill_at("not-a-timestamp"), @now) + end + + test "a missing kill_time is allowed through (fail-open)" do + assert DiscordDispatcher.kill_fresh?(%{"killmail_id" => 1}, @now) + end + + test "a non-string kill_time is allowed through (fail-open)" do + assert DiscordDispatcher.kill_fresh?(%{"killmail_id" => 1, "kill_time" => nil}, @now) + end + + test "an offset timestamp is compared in absolute time, not naively" do + # 13:30:00+02:00 is 11:30:00Z — thirty minutes old, well inside the hour. + assert DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T13:30:00+02:00"), @now) + end + + # Proves the guard reads the configured value rather than a hardcoded 3600. + test "honours a shortened configured max age" do + put_max_age(60) + + assert DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T11:59:30Z"), @now) + refute DiscordDispatcher.kill_fresh?(kill_at("2026-08-03T11:58:00Z"), @now) + end + end +end diff --git a/test/wanderer_app/external_events/discord/http_stub_test.exs b/test/wanderer_app/external_events/discord/http_stub_test.exs new file mode 100644 index 000000000..648fc4fd2 --- /dev/null +++ b/test/wanderer_app/external_events/discord/http_stub_test.exs @@ -0,0 +1,25 @@ +defmodule WandererApp.ExternalEvents.Discord.HttpStubTest do + use ExUnit.Case, async: false + + alias WandererApp.ExternalEvents.Discord.HttpStub + + setup do + {:ok, _pid} = HttpStub.start() + :ok + end + + test "records requests and returns default response when none scripted" do + assert {:ok, 204, []} = HttpStub.post("http://example.com", %{foo: "bar"}) + assert [{"http://example.com", %{foo: "bar"}}] = HttpStub.requests() + end + + test "returns queued responses in order" do + HttpStub.set_responses([{:ok, 429, [{"retry-after", "1"}]}, {:error, :timeout}]) + + assert {:ok, 429, [{"retry-after", "1"}]} = HttpStub.post("url1", %{a: 1}) + assert {:error, :timeout} = HttpStub.post("url2", %{b: 2}) + assert {:ok, 204, []} = HttpStub.post("url3", %{c: 3}) + + assert [{"url1", %{a: 1}}, {"url2", %{b: 2}}, {"url3", %{c: 3}}] = HttpStub.requests() + end +end diff --git a/test/wanderer_app/external_events/discord/matcher_test.exs b/test/wanderer_app/external_events/discord/matcher_test.exs new file mode 100644 index 000000000..5935f440a --- /dev/null +++ b/test/wanderer_app/external_events/discord/matcher_test.exs @@ -0,0 +1,189 @@ +defmodule WandererApp.ExternalEvents.Discord.MatcherTest do + use WandererApp.DataCase, async: false + + alias WandererApp.ExternalEvents.Discord.Matcher + + setup do + map = WandererAppWeb.Factory.insert(:map, %{}) + Matcher.invalidate_tracked(map.id) + + on_exit(fn -> + Matcher.invalidate_tracked(map.id) + Cachex.del(:map_cache, map.id) + end) + + %{map: map} + end + + describe "tracked_eve_ids/1" do + test "returns a MapSet of INTEGER eve ids, not strings", %{map: map} do + start_map_with_characters(map, ["95465499", "91000001"]) + + ids = Matcher.tracked_eve_ids(map.id) + + assert %MapSet{} = ids + assert MapSet.member?(ids, 95_465_499) + assert MapSet.member?(ids, 91_000_001) + + # The whole point of this task: a string-keyed set would satisfy the + # `member?` calls above only if the caller also passed strings, which it + # never does. Assert the element type directly. + assert Enum.all?(ids, &is_integer/1) + refute MapSet.member?(ids, "95465499") + end + + test "a character whose eve_id is a numeric string is found by integer id", %{map: map} do + start_map_with_characters(map, ["2117994022"]) + + assert MapSet.member?(Matcher.tracked_eve_ids(map.id), 2_117_994_022) + end + + test "returns an empty MapSet for a map that is not running" do + unknown_map_id = Ecto.UUID.generate() + + assert Matcher.tracked_eve_ids(unknown_map_id) == MapSet.new() + end + + test "does not cache the empty result of a failed lookup" do + unknown_map_id = Ecto.UUID.generate() + + assert Matcher.tracked_eve_ids(unknown_map_id) == MapSet.new() + + assert {:ok, nil} = + Cachex.get(:discord_notification_cache, "map:#{unknown_map_id}:tracked_eve_ids") + end + + test "an unresolvable character id costs one pilot, not the whole map's set", %{map: map} do + start_map_with_characters(map, ["95465499"]) + + # Simulate a stale id in `map.characters` whose backing character + # record no longer resolves (`get_map_character!/2` logs and returns + # `nil` for it rather than raising). + Cachex.get_and_update(:map_cache, map.id, fn stored_map -> + {:commit, Map.update!(stored_map, :characters, &[Ecto.UUID.generate() | &1])} + end) + + Matcher.invalidate_tracked(map.id) + + ids = Matcher.tracked_eve_ids(map.id) + assert MapSet.member?(ids, 95_465_499) + assert MapSet.size(ids) == 1 + end + end + + describe "invalidation" do + test "add_character/2 invalidates the cached set", %{map: map} do + start_map_with_characters(map, ["95465499"]) + assert MapSet.size(Matcher.tracked_eve_ids(map.id)) == 1 + + {:ok, newcomer} = + WandererApp.Api.Character.create(%{eve_id: "91000005", name: "Newcomer"}) + + WandererApp.Map.add_character(map.id, newcomer) + + ids = Matcher.tracked_eve_ids(map.id) + assert MapSet.member?(ids, 91_000_005) + assert MapSet.size(ids) == 2 + end + + test "remove_character/2 invalidates the cached set", %{map: map} do + [first | _] = start_map_with_characters(map, ["95465499", "91000001"]) + assert MapSet.size(Matcher.tracked_eve_ids(map.id)) == 2 + + WandererApp.Map.remove_character(map.id, first.id) + + ids = Matcher.tracked_eve_ids(map.id) + refute MapSet.member?(ids, 95_465_499) + assert MapSet.size(ids) == 1 + end + + test "add_characters!/2 (the bulk startup path) invalidates the cached set", %{map: map} do + start_map_with_characters(map, ["95465499"]) + # Warm the cache so a missing invalidation is observable. + assert MapSet.size(Matcher.tracked_eve_ids(map.id)) == 1 + + {:ok, bulk_one} = + WandererApp.Api.Character.create(%{eve_id: "91000006", name: "Bulk One"}) + + {:ok, bulk_two} = + WandererApp.Api.Character.create(%{eve_id: "91000007", name: "Bulk Two"}) + + map.id + |> WandererApp.Map.get_map!() + |> WandererApp.Map.add_characters!([ + %{character_id: bulk_one.id}, + %{character_id: bulk_two.id} + ]) + + ids = Matcher.tracked_eve_ids(map.id) + assert MapSet.member?(ids, 91_000_006) + assert MapSet.member?(ids, 91_000_007) + end + + test "invalidate_tracked/1 is idempotent and safe on a cold cache", %{map: map} do + assert Matcher.invalidate_tracked(map.id) == :ok + assert Matcher.invalidate_tracked(map.id) == :ok + end + + # The race the version stamp exists to close, driven deterministically: a + # build reads the tracked set, an `invalidate_tracked/1` lands while that + # build is still in flight, and the build then tries to write. Before the + # version check, that write put the PRE-delete set back and the stale entry + # survived the full five-minute TTL — an invalidation that silently did + # nothing, which is the worst possible outcome for a routing cache. + test "an invalidation during a build is not undone by that build's write", %{map: map} do + stale = MapSet.new([95_465_499]) + + build_fun = fn _map_id -> + # Lands after the version read, before the write. Exactly the window. + Matcher.invalidate_tracked(map.id) + {:ok, stale} + end + + # The caller still gets the set — it was current when the build began, + # and this killmail has to route somewhere. + assert Matcher.build_and_cache(map.id, build_fun) == stale + + # But it must NOT be readable afterwards: the next killmail rebuilds. + assert {:ok, nil} = + Cachex.get(:discord_notification_cache, "map:#{map.id}:tracked_eve_ids") + end + + # The control for the test above. If `cache_put/3` rejected every write, + # that test would pass while the cache never worked at all. + test "an uninterrupted build does write its set back", %{map: map} do + fresh = MapSet.new([95_465_499]) + + assert Matcher.build_and_cache(map.id, fn _ -> {:ok, fresh} end) == fresh + + assert {:ok, ^fresh} = + Cachex.get(:discord_notification_cache, "map:#{map.id}:tracked_eve_ids") + end + end + + # Seeds the in-memory map cache entry that `WandererApp.Map`'s cache-backed + # functions (`get_map!/1`, `update_map/2`, `list_characters/1`) read and + # write directly — there is no map GenServer to start; `add_character/2`, + # `remove_character/2` and `add_characters!/2` all operate on `:map_cache` + # via `Cachex.get_and_update/3`, not via a process call. Characters are then + # registered via the same public writer production uses, so the test + # exercises the real invalidation path. + defp start_map_with_characters(map, eve_ids) do + Cachex.put(:map_cache, map.id, %{map_id: map.id, characters: []}) + + characters = + Enum.map(eve_ids, fn eve_id -> + {:ok, character} = + WandererApp.Api.Character.create(%{ + eve_id: eve_id, + name: "Pilot #{eve_id}" + }) + + WandererApp.Map.add_character(map.id, character) + character + end) + + Matcher.invalidate_tracked(map.id) + characters + end +end From 81148589a962c1e9762ae5b8afb69ff194bab39a Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sat, 8 Aug 2026 17:59:18 +0000 Subject: [PATCH 15/94] zoo(feat): add killmail ingest and corporation search --- lib/wanderer_app/esi/api_client.ex | 31 +- lib/wanderer_app/esi/corporation_search.ex | 129 ++++++ lib/wanderer_app/kills/client.ex | 73 +++- lib/wanderer_app/kills/message_handler.ex | 144 +++++-- lib/wanderer_app/system_class.ex | 73 ++++ .../unit/esi/api_client_token_expiry_test.exs | 67 ++++ test/unit/esi/corporation_search_test.exs | 136 +++++++ test/unit/kills/client_backoff_test.exs | 94 +++++ .../kills/message_handler_attackers_test.exs | 373 ++++++++++++++++++ test/unit/system_class_test.exs | 76 ++++ 10 files changed, 1162 insertions(+), 34 deletions(-) create mode 100644 lib/wanderer_app/esi/corporation_search.ex create mode 100644 lib/wanderer_app/system_class.ex create mode 100644 test/unit/esi/api_client_token_expiry_test.exs create mode 100644 test/unit/esi/corporation_search_test.exs create mode 100644 test/unit/kills/client_backoff_test.exs create mode 100644 test/unit/kills/message_handler_attackers_test.exs create mode 100644 test/unit/system_class_test.exs diff --git a/lib/wanderer_app/esi/api_client.ex b/lib/wanderer_app/esi/api_client.ex index 58ab7c4cb..f78447a6c 100644 --- a/lib/wanderer_app/esi/api_client.ex +++ b/lib/wanderer_app/esi/api_client.ex @@ -292,13 +292,30 @@ defmodule WandererApp.Esi.ApiClient do end end - defp is_access_token_expired?(character_id) do - {:ok, %{expires_at: expires_at} = _character} = - WandererApp.Character.get_character(character_id) - - now = DateTime.utc_now() |> DateTime.to_unix() - - expires_at - now <= 0 + @doc false + # Answers "can we prove this character's access token is still valid?". + # + # Only an integer `expires_at` in the future counts as not-expired. Every other + # shape means we cannot prove validity, so we report expired and let the caller + # take the refresh-and-retry path: + # + # * `expires_at` is nullable on `WandererApp.Api.Character`, so a character + # that has never completed an OAuth exchange carries `nil`. + # * `WandererApp.Character.get_character/1` answers `{:ok, nil}` for a nil id + # and `{:error, :not_found}` for an id that is not in the cache or the DB. + # + # Previously all three raised (ArithmeticError / MatchError) on *every* + # authenticated ESI call — location, online, ship, wallet and search — rather + # than on the corporation search where it was first observed. + def is_access_token_expired?(character_id) do + case WandererApp.Character.get_character(character_id) do + {:ok, %{expires_at: expires_at}} when is_integer(expires_at) -> + now = DateTime.utc_now() |> DateTime.to_unix() + expires_at - now <= 0 + + _other -> + true + end end defp get_corporation_auth_data(corporation_eve_id, info_path, opts), diff --git a/lib/wanderer_app/esi/corporation_search.ex b/lib/wanderer_app/esi/corporation_search.ex new file mode 100644 index 000000000..cbf80e463 --- /dev/null +++ b/lib/wanderer_app/esi/corporation_search.ex @@ -0,0 +1,129 @@ +defmodule WandererApp.Esi.CorporationSearch do + @moduledoc """ + Corporation name search against ESI, performed as one of the user's characters. + + ESI's `/search/` endpoint is authenticated, so a search needs a character with + a live access token — which is why this takes a character list rather than a + bare query string. Extracted from `MapSystemsEventHandler` so the map UI and + the notification settings component share one implementation of the + minimum-length rule and the ticker enrichment. + """ + + require Logger + + alias WandererApp.Character + + @min_search_length 3 + + # ESI returns every corporation whose name matches the prefix, and `decorate/1` + # issues one sequential `get_corporation_info/1` per hit — inside the calling + # LiveView process, on every debounced keystroke. A broad term like "corp" + # would otherwise block the settings tab for the sum of hundreds of lookups + # whose results the caller then truncates anyway. Cap first, enrich after. + @max_results 20 + + @doc "Minimum number of characters before a search is sent to ESI." + @spec min_search_length() :: pos_integer() + def min_search_length, do: @min_search_length + + @doc "Maximum number of hits enriched and returned by `search/3`." + @spec max_results() :: pos_integer() + def max_results, do: @max_results + + @doc """ + Searches corporations by name as the first of `characters`. + + Returns `{:ok, []}` when the user has no characters or the term is too short, + so callers can render "no matches" without distinguishing those cases from a + genuinely empty result. An ESI failure is passed through as `{:error, reason}` + — both callers degrade that to an empty dropdown, but the tuple is not + swallowed here. + + At most `max_results/0` hits are returned. + + Each hit keeps the keys `Character.search/2` produced (`:label`, `:value`, + `:corporation`) and adds `:formatted`, `:name`, `:ticker`, `:id`, `:type`. + `:value` and `:id` are **strings**; callers that persist integers must convert. + + `opts` exists for tests: `:search_fun` replaces `Character.search/2` and + `:fetch_fun` replaces the ticker lookup. + """ + @spec search(list(), any(), keyword()) :: {:ok, list(map())} | {:error, term()} + def search(characters, search, opts \\ []) + + def search([], _search, _opts), do: {:ok, []} + + def search([first_char | _], search, opts) when is_binary(search) do + if String.length(search) < @min_search_length do + {:ok, []} + else + search_fun = Keyword.get(opts, :search_fun, &Character.search/2) + fetch_fun = Keyword.get(opts, :fetch_fun, &WandererApp.Esi.get_corporation_info/1) + + case search_fun.(first_char.id, params: [search: search, categories: "corporation"]) do + {:ok, results} -> + {:ok, results |> Enum.take(@max_results) |> Enum.map(&decorate(&1, fetch_fun))} + + other -> + other + end + end + end + + def search(_characters, _search, _opts), do: {:ok, []} + + @doc """ + Human-readable label for a stored corporation id. + + Falls back to `to_string(corp_id)` whenever ESI cannot answer: a saved focus + corporation has to stay visible and removable while ESI is down. + """ + @spec label_for(integer() | String.t()) :: String.t() + @spec label_for(integer() | String.t(), (any() -> any())) :: String.t() + def label_for(corp_id, fetch_fun \\ &WandererApp.Esi.get_corporation_info/1) do + case safe_fetch(fetch_fun, corp_id) do + {:ok, %{"name" => name} = info} when is_binary(name) and name != "" -> + format_label(name, Map.get(info, "ticker")) + + _ -> + to_string(corp_id) + end + end + + defp decorate(item, fetch_fun) do + name = Map.get(item, :label, "") + corp_id = Map.get(item, :value, "") + + ticker = + case safe_fetch(fetch_fun, corp_id) do + {:ok, %{"ticker" => ticker}} -> ticker + _ -> "" + end + + Map.merge(item, %{ + formatted: format_label(name, ticker), + name: name, + ticker: ticker, + id: corp_id, + type: "corp" + }) + end + + defp format_label(name, ticker) when is_binary(ticker) and ticker != "", + do: "[#{ticker}] #{name}" + + defp format_label(name, _ticker), do: name + + # ESI is a network dependency reached from a LiveView process; a raise here + # would take the settings tab down over a transient lookup. + defp safe_fetch(fetch_fun, corp_id) do + fetch_fun.(corp_id) + rescue + error -> + Logger.warning( + "[CorporationSearch] lookup failed for #{inspect(corp_id)}: #{inspect(error)}" + ) + + :error + end +end diff --git a/lib/wanderer_app/kills/client.ex b/lib/wanderer_app/kills/client.ex index 8ec49fbe3..514b401b1 100644 --- a/lib/wanderer_app/kills/client.ex +++ b/lib/wanderer_app/kills/client.ex @@ -12,8 +12,18 @@ defmodule WandererApp.Kills.Client do alias WandererApp.Kills.Subscription.{Manager, MapIntegration} alias Phoenix.Channels.GenSocketClient - # Simple retry configuration - inline like character module - @retry_delays [5_000, 10_000, 30_000, 60_000] + # Reconnect backoff: exponential from 1s to a 60s ceiling, plus ~30% jitter. + # The jitter matters operationally — without it every instance that lost the + # upstream at the same moment reconnects at the same moment, turning one blip + # into a synchronized thundering herd against the kills service. + @retry_base_delay_ms 1_000 + @retry_max_delay_ms 60_000 + @retry_jitter_fraction 0.3 + # A floor, so a pathological jitter draw can never schedule an immediate retry. + @retry_min_delay_ms 100 + # Caps the exponent so `Integer.pow/2` cannot blow up if retry_count is ever + # raised well above @max_retries. 2^16 * 1s is already far past the ceiling. + @retry_max_exponent 16 @max_retries 10 # Check every 30 seconds @health_check_interval :timer.seconds(30) @@ -85,6 +95,44 @@ defmodule WandererApp.Kills.Client do :ok end + @doc """ + Delay before the next reconnect attempt, in milliseconds. + + Exponential from #{@retry_base_delay_ms}ms, capped at #{@retry_max_delay_ms}ms, + with a jitter offset of up to ±#{trunc(@retry_jitter_fraction * 100)}%. + + ## Why `rand_fun` is an argument + + Public and injectable on purpose. With the random source pinned a test can + assert the *exact* delay sequence; a function that called `:rand.uniform/1` + internally could only be range-asserted, and a range assertion does not + distinguish a ceiling applied before jitter from one applied after. The + before-jitter version silently schedules retries past the ceiling. + + `rand_fun` follows the `:rand.uniform/1` contract: given `n`, it returns an + integer in `1..n`. + """ + @spec retry_delay_ms(non_neg_integer(), (pos_integer() -> pos_integer())) :: pos_integer() + def retry_delay_ms(retry_count, rand_fun \\ &:rand.uniform/1) + when is_integer(retry_count) and retry_count >= 0 and is_function(rand_fun, 1) do + base = + @retry_base_delay_ms + |> Kernel.*(Integer.pow(2, min(retry_count, @retry_max_exponent))) + |> min(@retry_max_delay_ms) + + span = trunc(base * @retry_jitter_fraction) + + # rand_fun.(2 * span + 1) is in 1..2*span+1, so the offset is in -span..span. + # The +1 keeps the argument positive when span is 0. + offset = rand_fun.(2 * span + 1) - span - 1 + + # The ceiling is re-applied HERE, after the offset. Applying it only to + # `base` above would let the top of the jitter range exceed it. + (base + offset) + |> min(@retry_max_delay_ms) + |> max(@retry_min_delay_ms) + end + # Server callbacks @impl true def init(_opts) do @@ -392,7 +440,21 @@ defmodule WandererApp.Kills.Client do {:error, reason} -> Logger.error("[Client] Connection failed: #{inspect(reason)}") - schedule_retry(%{state | connecting: false, last_error: reason}) + state = %{state | connecting: false, last_error: reason} + + # Gated on `should_retry?/1` for the same reason the async failure path + # at `handle_info({:socket_error, ...})` is: scheduling unconditionally + # means an exhausted retry budget still queues another reconnect, so the + # 15-minute retry-cycle cooldown in `check_health/1` never gets to run. + if should_retry?(state) do + schedule_retry(state) + else + Logger.error( + "[Client] Max retry attempts (#{@max_retries}) reached. Will not retry automatically." + ) + + state + end end end @@ -484,7 +546,10 @@ defmodule WandererApp.Kills.Client do state end - delay = Enum.at(@retry_delays, min(state.retry_count, length(@retry_delays) - 1)) + # `state.retry_count` is the PRE-increment value, matching the previous + # `Enum.at/2` indexing: the first retry after a disconnect backs off by one + # base interval, not two. + delay = retry_delay_ms(state.retry_count) timer_ref = Process.send_after(self(), :retry_connection, delay) %{state | retry_timer_ref: timer_ref, retry_count: new_retry_count} diff --git a/lib/wanderer_app/kills/message_handler.ex b/lib/wanderer_app/kills/message_handler.ex index b2c2fedd1..a24e9905b 100644 --- a/lib/wanderer_app/kills/message_handler.ex +++ b/lib/wanderer_app/kills/message_handler.ex @@ -177,15 +177,18 @@ defmodule WandererApp.Kills.MessageHandler do @type killmail :: map() @type adapter_result :: {:ok, killmail()} | {:error, term()} + @doc false + # Public only so the flattening logic can be tested directly; production + # callers go through `process_killmail_update/1`. @spec adapt_kill_data(any()) :: adapter_result() # Pattern match on zkillboard format - not supported - defp adapt_kill_data(%{"killID" => kill_id}) do + def adapt_kill_data(%{"killID" => kill_id}) do Logger.warning("[MessageHandler] Zkillboard format not supported: killID=#{kill_id}") {:error, :zkillboard_format_not_supported} end # Pattern match on flat format - already adapted - defp adapt_kill_data(%{"victim_char_id" => _} = kill) do + def adapt_kill_data(%{"victim_char_id" => _} = kill) do validated_kill = validate_flat_format_kill(kill) if map_size(validated_kill) > 0 do @@ -197,14 +200,14 @@ defmodule WandererApp.Kills.MessageHandler do end # Pattern match on nested format with valid structure - defp adapt_kill_data( - %{ - "killmail_id" => killmail_id, - "kill_time" => _kill_time, - "victim" => victim - } = kill - ) - when is_map(victim) do + def adapt_kill_data( + %{ + "killmail_id" => killmail_id, + "kill_time" => _kill_time, + "victim" => victim + } = kill + ) + when is_map(victim) do # Validate and normalize IDs first with {:ok, valid_killmail_id} <- validate_killmail_id(killmail_id), {:ok, valid_system_id} <- get_and_validate_system_id(kill) do @@ -232,7 +235,7 @@ defmodule WandererApp.Kills.MessageHandler do end # Invalid data type - defp adapt_kill_data(invalid_data) do + def adapt_kill_data(invalid_data) do data_type = if(is_nil(invalid_data), do: "nil", else: "#{inspect(invalid_data)}") Logger.warning("[MessageHandler] Invalid data type: #{data_type}") {:error, :invalid_format} @@ -260,7 +263,9 @@ defmodule WandererApp.Kills.MessageHandler do @spec adapt_nested_format_kill(map()) :: map() defp adapt_nested_format_kill(kill) do victim = kill["victim"] - attackers = Map.get(kill, "attackers", []) + # Raw, undefaulted lookup: nil means "attackers" was absent (or explicitly + # nil), which is different from a payload that carried an empty list. + attackers = kill["attackers"] zkb = Map.get(kill, "zkb", %{}) # Validate attackers is a list @@ -273,6 +278,7 @@ defmodule WandererApp.Kills.MessageHandler do |> add_victim_data(victim) |> add_final_blow_attacker_data(final_blow_attacker) |> add_kill_statistics(attackers_list, zkb) + |> maybe_add_attacker_identity_data(attackers, attackers_list) # Validate that critical output fields are present case validate_required_output_fields(adapted_kill) do @@ -320,18 +326,24 @@ defmodule WandererApp.Kills.MessageHandler do end @spec add_final_blow_attacker_data(map(), map()) :: map() - defp add_final_blow_attacker_data(acc, attacker) do + defp add_final_blow_attacker_data(acc, attacker), + do: add_prefixed_attacker_data(acc, attacker, "final_blow") + + # Shared by the final-blow and top-damage attackers so the two can never + # drift in how names, tickers and ids are read from an attacker map. + @spec add_prefixed_attacker_data(map(), map(), String.t()) :: map() + defp add_prefixed_attacker_data(acc, attacker, prefix) do attacker_data = %{ - "final_blow_char_id" => attacker["character_id"], - "final_blow_char_name" => get_character_name(attacker), - "final_blow_corp_id" => attacker["corporation_id"], - "final_blow_corp_ticker" => get_corp_ticker(attacker), - "final_blow_corp_name" => get_corp_name(attacker), - "final_blow_alliance_id" => attacker["alliance_id"], - "final_blow_alliance_ticker" => get_alliance_ticker(attacker), - "final_blow_alliance_name" => get_alliance_name(attacker), - "final_blow_ship_type_id" => attacker["ship_type_id"], - "final_blow_ship_name" => get_ship_name(attacker) + "#{prefix}_char_id" => attacker["character_id"], + "#{prefix}_char_name" => get_character_name(attacker), + "#{prefix}_corp_id" => attacker["corporation_id"], + "#{prefix}_corp_ticker" => get_corp_ticker(attacker), + "#{prefix}_corp_name" => get_corp_name(attacker), + "#{prefix}_alliance_id" => attacker["alliance_id"], + "#{prefix}_alliance_ticker" => get_alliance_ticker(attacker), + "#{prefix}_alliance_name" => get_alliance_name(attacker), + "#{prefix}_ship_type_id" => attacker["ship_type_id"], + "#{prefix}_ship_name" => get_ship_name(attacker) } Map.merge(acc, attacker_data) @@ -346,6 +358,72 @@ defmodule WandererApp.Kills.MessageHandler do }) end + # Attacker identity, retained for Discord involvement matching. Deliberately + # separate from `add_kill_statistics/3`, which is about aggregates and + # discards the attacker list after taking its length. + # + # Every field produced here is OPTIONAL: `@required_output_fields` must not + # grow. Empty lists are a valid result (an all-NPC kill has no pilots), and + # nil top-damage fields are valid (an all-NPC kill has no top-damage pilot). + # + # Only attach these keys when the payload genuinely carried an "attackers" + # list — even an empty one. An absent or malformed "attackers" value means + # "we don't know who attacked", which Task 7 must be able to tell apart from + # "we know, and it was nobody": leaving all six keys absent signals the + # former, an empty list signals the latter. + @spec maybe_add_attacker_identity_data(map(), any(), list()) :: map() + defp maybe_add_attacker_identity_data(acc, attackers, attackers_list) + when is_list(attackers) do + add_attacker_identity_data(acc, attackers_list) + end + + defp maybe_add_attacker_identity_data(acc, _attackers, _attackers_list), do: acc + + @spec add_attacker_identity_data(map(), list()) :: map() + defp add_attacker_identity_data(acc, attackers_list) do + top_damage_attacker = find_top_damage_attacker(attackers_list) + + acc + |> Map.merge(%{ + "attacker_char_ids" => collect_ids(attackers_list, "character_id"), + "attacker_corp_ids" => collect_ids(attackers_list, "corporation_id") + }) + |> add_prefixed_attacker_data(top_damage_attacker, "top_damage") + end + + # NPC attackers carry no character or corporation id, so nils are dropped + # rather than retained as a nil member that could never match anything. + # + # Ids are normalized to integers here, at the source, so every downstream + # consumer (Discord.Matcher's tracked-pilot set, Task 7's involvement + # check) can compare without coercion. A payload carrying a string id + # (`"character_id" => "91000001"`) would otherwise put a binary in this + # list, silently failing to match an integer set. + @spec collect_ids(list(), String.t()) :: [integer()] + defp collect_ids(attackers_list, key) do + attackers_list + |> Enum.filter(&is_map/1) + |> Enum.map(&Map.get(&1, key)) + |> Enum.map(&normalize_id/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + # Wire values are normally integers already; a numeric string is accepted + # (parsed in full — `"12345abc"` and `" 12345"` are rejected, not + # truncated, via the `{id, ""}` guard) and anything else is dropped. + @spec normalize_id(term()) :: integer() | nil + defp normalize_id(id) when is_integer(id), do: id + + defp normalize_id(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> parsed + _ -> nil + end + end + + defp normalize_id(_), do: nil + # Critical fields that the frontend expects to be present in killmail data @required_output_fields [ "killmail_id", @@ -390,6 +468,26 @@ defmodule WandererApp.Kills.MessageHandler do defp find_final_blow_attacker(_), do: %{} + # Mirrors `find_final_blow_attacker/1`: returns `%{}` when there is nothing + # to pick, so the downstream extractor yields nils rather than crashing. + @spec find_top_damage_attacker(list(map()) | any()) :: map() + defp find_top_damage_attacker([]), do: %{} + + defp find_top_damage_attacker(attackers) when is_list(attackers) do + attackers + |> Enum.filter(&is_map/1) + |> Enum.max_by(&damage_done/1, fn -> %{} end) + end + + defp find_top_damage_attacker(_), do: %{} + + # `damage_done` is occasionally absent or non-numeric in upstream payloads; + # treat those attackers as having dealt no damage rather than crashing the + # whole killmail's adaptation. + @spec damage_done(map()) :: number() + defp damage_done(%{"damage_done" => damage}) when is_number(damage), do: damage + defp damage_done(_), do: 0 + # Generic field extraction with multiple possible field names @spec extract_field(map(), list(String.t())) :: String.t() | nil defp extract_field(data, field_names) when is_map(data) and is_list(field_names) do diff --git a/lib/wanderer_app/system_class.ex b/lib/wanderer_app/system_class.ex new file mode 100644 index 000000000..4a64b3101 --- /dev/null +++ b/lib/wanderer_app/system_class.ex @@ -0,0 +1,73 @@ +defmodule WandererApp.SystemClass do + @moduledoc """ + Canonical wormhole classification for EVE solar systems. + + Single source of truth for "is this class wormhole space", shared by the map + server's connection scoping and by server-side kill notifications. Mirrors + `assets/js/hooks/Mapper/components/map/helpers/isWormholeSpace.ts`. + """ + + require Logger + + @c1 1 + @c2 2 + @c3 3 + @c4 4 + @c5 5 + @c6 6 + @thera 12 + @c13 13 + @sentinel 14 + @barbican 15 + @vidette 16 + @conflux 17 + @redoubt 18 + + # c1-c6, Thera, c13 shattered frigate holes, and the five drifter systems. + @wormhole_classes [ + @c1, + @c2, + @c3, + @c4, + @c5, + @c6, + @c13, + @thera, + @sentinel, + @barbican, + @vidette, + @conflux, + @redoubt + ] + + @doc """ + The wormhole class ids. Exposed so callers needing a compile-time list (for + `in` checks in guards or hot paths) can derive theirs from this one rather + than restating it. + """ + @spec wormhole_classes() :: [pos_integer()] + def wormhole_classes, do: @wormhole_classes + + @spec wormhole?(integer() | nil) :: boolean() + def wormhole?(class) when class in @wormhole_classes, do: true + def wormhole?(_), do: false + + @doc """ + Resolves a solar system id to its class and reports whether it is wormhole + space. Returns false when static info cannot be resolved. + """ + @spec wormhole_system?(integer()) :: boolean() + def wormhole_system?(solar_system_id) do + case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do + {:ok, %{system_class: class}} -> + wormhole?(class) + + other -> + Logger.warning( + "[SystemClass] could not resolve static info for #{inspect(solar_system_id)}: #{inspect(other)}" + ) + + false + end + end +end diff --git a/test/unit/esi/api_client_token_expiry_test.exs b/test/unit/esi/api_client_token_expiry_test.exs new file mode 100644 index 000000000..e2789ea58 --- /dev/null +++ b/test/unit/esi/api_client_token_expiry_test.exs @@ -0,0 +1,67 @@ +defmodule WandererApp.Esi.ApiClientTokenExpiryTest do + @moduledoc """ + Regression coverage for `WandererApp.Esi.ApiClient.is_access_token_expired?/1`. + + Every authenticated ESI call routes through this predicate — location, online, + ship, wallet and search — so a raise here takes down character tracking, not + just the corporation search where it was first observed. + """ + use WandererApp.DataCase, async: true + + alias WandererApp.Esi.ApiClient + + setup do + character_id = "token-expiry-#{System.unique_integer([:positive])}" + on_exit(fn -> Cachex.del(:character_cache, character_id) end) + %{character_id: character_id} + end + + defp cache_character(character_id, character), + do: Cachex.put(:character_cache, character_id, character) + + defp unix_now, do: DateTime.utc_now() |> DateTime.to_unix() + + test "a token expiring in the future is not expired", %{character_id: character_id} do + cache_character(character_id, %{expires_at: unix_now() + 3600}) + + refute ApiClient.is_access_token_expired?(character_id) + end + + test "a token whose expiry has passed is expired", %{character_id: character_id} do + cache_character(character_id, %{expires_at: unix_now() - 1}) + + assert ApiClient.is_access_token_expired?(character_id) + end + + test "a token expiring exactly now is expired", %{character_id: character_id} do + cache_character(character_id, %{expires_at: unix_now()}) + + assert ApiClient.is_access_token_expired?(character_id) + end + + test "a nil expires_at is treated as expired rather than raising", %{ + character_id: character_id + } do + # `expires_at` is nullable on WandererApp.Api.Character (no `allow_nil? false`), + # so a character that never completed an OAuth exchange carries nil. Arithmetic + # on it used to raise ArithmeticError on every authenticated ESI call. + cache_character(character_id, %{expires_at: nil}) + + assert ApiClient.is_access_token_expired?(character_id) + end + + test "a nil character_id is treated as expired rather than raising" do + # WandererApp.Character.get_character/1 answers {:ok, nil} for a nil id, which + # used to fail the `{:ok, %{expires_at: _}} =` destructure with a MatchError. + assert ApiClient.is_access_token_expired?(nil) + end + + test "an unknown character_id is treated as expired rather than raising" do + # Not in the cache and not in the DB => {:error, :not_found}, another MatchError. + # `DataCase` rather than a bare `ExUnit.Case`: without a sandbox checkout the + # Ash read fails on connection ownership, Ash wraps that into an error tuple, + # and the assertion passes for the wrong reason — "DB unreachable" instead of + # "row absent". + assert ApiClient.is_access_token_expired?(Ecto.UUID.generate()) + end +end diff --git a/test/unit/esi/corporation_search_test.exs b/test/unit/esi/corporation_search_test.exs new file mode 100644 index 000000000..bb0cde584 --- /dev/null +++ b/test/unit/esi/corporation_search_test.exs @@ -0,0 +1,136 @@ +defmodule WandererApp.Esi.CorporationSearchTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Esi.CorporationSearch + + describe "search/2" do + test "returns no results when the user has no characters" do + assert {:ok, []} = CorporationSearch.search([], "Karmafleet") + end + + test "returns no results below the minimum search length" do + # Two characters is under the three-character minimum, so this must not + # reach ESI at all. A character id that does not exist would make any + # actual lookup fail loudly. + assert {:ok, []} = + CorporationSearch.search([%{id: Ecto.UUID.generate()}], "Ka") + end + + test "returns no results for a non-binary search term" do + assert {:ok, []} = CorporationSearch.search([%{id: Ecto.UUID.generate()}], nil) + end + + test "min_search_length is three, matching the pre-extraction behaviour" do + assert CorporationSearch.min_search_length() == 3 + end + + test "decorates each hit with the shape both callers render" do + search_fun = fn _char_id, _opts -> + {:ok, [%{label: "Karmafleet", value: "98000001", corporation: true}]} + end + + fetch_fun = fn "98000001" -> {:ok, %{"name" => "Karmafleet", "ticker" => "KARMA"}} end + + assert {:ok, [hit]} = + CorporationSearch.search([%{id: Ecto.UUID.generate()}], "Karmafleet", + search_fun: search_fun, + fetch_fun: fetch_fun + ) + + # The keys `Character.search/2` produced survive alongside the added ones. + assert hit.label == "Karmafleet" + assert hit.corporation == true + + assert hit.formatted == "[KARMA] Karmafleet" + assert hit.name == "Karmafleet" + assert hit.ticker == "KARMA" + # Documented as strings: callers that persist integers must convert. + assert hit.id == "98000001" + assert hit.value == "98000001" + assert hit.type == "corp" + end + + test "a hit whose ticker lookup fails still renders its bare name" do + search_fun = fn _char_id, _opts -> {:ok, [%{label: "Karmafleet", value: "98000001"}]} end + fetch_fun = fn _ -> {:error, :timeout} end + + assert {:ok, [hit]} = + CorporationSearch.search([%{id: Ecto.UUID.generate()}], "Karmafleet", + search_fun: search_fun, + fetch_fun: fetch_fun + ) + + assert hit.formatted == "Karmafleet" + assert hit.ticker == "" + end + + test "caps the hits at max_results/0 BEFORE enriching them" do + over_cap = CorporationSearch.max_results() + 15 + + results = + Enum.map(1..over_cap, fn n -> + %{label: "Corp #{n}", value: to_string(98_000_000 + n)} + end) + + search_fun = fn _char_id, _opts -> {:ok, results} end + + # Counting the enrichment calls is the point: capping after enrichment + # would return the right length while still issuing one sequential ESI + # lookup per hit, which is the block this cap exists to prevent. + {:ok, counter} = Agent.start_link(fn -> 0 end) + on_exit(fn -> if Process.alive?(counter), do: Agent.stop(counter) end) + + fetch_fun = fn _ -> + Agent.update(counter, &(&1 + 1)) + {:ok, %{"name" => "Corp", "ticker" => "C"}} + end + + assert {:ok, hits} = + CorporationSearch.search([%{id: Ecto.UUID.generate()}], "Corp", + search_fun: search_fun, + fetch_fun: fetch_fun + ) + + assert length(hits) == CorporationSearch.max_results() + assert Agent.get(counter, & &1) == CorporationSearch.max_results() + end + + test "passes an ESI failure through instead of swallowing it" do + search_fun = fn _char_id, _opts -> {:error, :forbidden} end + + assert {:error, :forbidden} = + CorporationSearch.search([%{id: Ecto.UUID.generate()}], "Karmafleet", + search_fun: search_fun + ) + end + end + + describe "label_for/2" do + test "renders ticker and name when ESI answers" do + fetch = fn 98_000_001 -> {:ok, %{"name" => "Karmafleet", "ticker" => "KARMA"}} end + + assert CorporationSearch.label_for(98_000_001, fetch) == "[KARMA] Karmafleet" + end + + test "renders the bare name when ESI answers without a ticker" do + fetch = fn 98_000_001 -> {:ok, %{"name" => "Karmafleet", "ticker" => ""}} end + + assert CorporationSearch.label_for(98_000_001, fetch) == "Karmafleet" + end + + test "falls back to the bare id when ESI fails" do + # A saved focus corporation must still render as a removable chip while + # ESI is down. Dropping it would look like the setting was lost, and the + # user has no way to un-set what is not rendered. + fetch = fn _ -> {:error, :timeout} end + + assert CorporationSearch.label_for(98_000_001, fetch) == "98000001" + end + + test "falls back to the bare id when ESI raises" do + fetch = fn _ -> raise "boom" end + + assert CorporationSearch.label_for(98_000_001, fetch) == "98000001" + end + end +end diff --git a/test/unit/kills/client_backoff_test.exs b/test/unit/kills/client_backoff_test.exs new file mode 100644 index 000000000..a8c72c26e --- /dev/null +++ b/test/unit/kills/client_backoff_test.exs @@ -0,0 +1,94 @@ +defmodule WandererApp.Kills.ClientBackoffTest do + # `retry_delay_ms/2` is pure: no app env, no cache, no process state. + use ExUnit.Case, async: true + + alias WandererApp.Kills.Client + + # `:rand.uniform/1` returns an integer in 1..n. These three stand-ins pin it to + # the bottom, middle and top of that range, which map to the minimum, zero and + # maximum jitter offsets respectively. + # + # They are functions rather than module attributes on purpose: an anonymous + # function cannot be stored in a module attribute (it is not a valid + # compile-time value). + defp min_jitter, do: fn _n -> 1 end + defp no_jitter, do: fn n -> div(n + 1, 2) end + defp max_jitter, do: fn n -> n end + + describe "retry_delay_ms/2 with jitter pinned to zero" do + test "doubles from 1s and holds at the 60s ceiling" do + assert Client.retry_delay_ms(0, no_jitter()) == 1_000 + assert Client.retry_delay_ms(1, no_jitter()) == 2_000 + assert Client.retry_delay_ms(2, no_jitter()) == 4_000 + assert Client.retry_delay_ms(3, no_jitter()) == 8_000 + assert Client.retry_delay_ms(4, no_jitter()) == 16_000 + assert Client.retry_delay_ms(5, no_jitter()) == 32_000 + assert Client.retry_delay_ms(6, no_jitter()) == 60_000 + assert Client.retry_delay_ms(7, no_jitter()) == 60_000 + assert Client.retry_delay_ms(10, no_jitter()) == 60_000 + end + end + + describe "retry_delay_ms/2 with jitter pinned to its minimum" do + test "produces exactly the base minus 30%" do + assert Client.retry_delay_ms(0, min_jitter()) == 700 + assert Client.retry_delay_ms(1, min_jitter()) == 1_400 + assert Client.retry_delay_ms(2, min_jitter()) == 2_800 + assert Client.retry_delay_ms(3, min_jitter()) == 5_600 + assert Client.retry_delay_ms(4, min_jitter()) == 11_200 + assert Client.retry_delay_ms(5, min_jitter()) == 22_400 + assert Client.retry_delay_ms(6, min_jitter()) == 42_000 + assert Client.retry_delay_ms(9, min_jitter()) == 42_000 + end + end + + describe "retry_delay_ms/2 with jitter pinned to its maximum" do + test "produces exactly the base plus 30% below the ceiling" do + assert Client.retry_delay_ms(0, max_jitter()) == 1_300 + assert Client.retry_delay_ms(1, max_jitter()) == 2_600 + assert Client.retry_delay_ms(2, max_jitter()) == 5_200 + assert Client.retry_delay_ms(3, max_jitter()) == 10_400 + assert Client.retry_delay_ms(4, max_jitter()) == 20_800 + assert Client.retry_delay_ms(5, max_jitter()) == 41_600 + end + + # THE regression this design guards against. With the ceiling applied only + # before jitter, retry 6 computes min(64_000, 60_000) = 60_000, then adds + # +18_000 for 78_000 — over the ceiling. The ceiling must be applied again + # after the offset. + test "the ceiling holds AFTER jitter is applied, not before" do + assert Client.retry_delay_ms(6, max_jitter()) == 60_000 + assert Client.retry_delay_ms(7, max_jitter()) == 60_000 + assert Client.retry_delay_ms(20, max_jitter()) == 60_000 + end + end + + describe "retry_delay_ms/2 invariants across the real random source" do + test "no delay ever exceeds the 60s ceiling or drops to zero" do + for retry_count <- 0..20, _ <- 1..50 do + delay = Client.retry_delay_ms(retry_count) + + assert delay > 0, "retry #{retry_count} produced a non-positive delay #{delay}" + assert delay <= 60_000, "retry #{retry_count} produced #{delay}, over the ceiling" + end + end + + test "the first retry is not zero" do + for _ <- 1..100 do + assert Client.retry_delay_ms(0) >= 700 + end + end + + test "the default rand_fun is used when the second argument is omitted" do + assert is_integer(Client.retry_delay_ms(3)) + end + + # Not a strict ordering assertion — jitter ranges overlap between adjacent + # steps. This asserts the *envelope* grows, which a broken exponent would + # not. + test "later retries back off further than earlier ones" do + assert Client.retry_delay_ms(0, max_jitter()) < Client.retry_delay_ms(2, min_jitter()) + assert Client.retry_delay_ms(2, max_jitter()) < Client.retry_delay_ms(4, min_jitter()) + end + end +end diff --git a/test/unit/kills/message_handler_attackers_test.exs b/test/unit/kills/message_handler_attackers_test.exs new file mode 100644 index 000000000..0ae39eba6 --- /dev/null +++ b/test/unit/kills/message_handler_attackers_test.exs @@ -0,0 +1,373 @@ +defmodule WandererApp.Kills.MessageHandlerAttackersTest do + use ExUnit.Case, async: true + + alias WandererApp.Kills.MessageHandler + + # A realistic nested killmail: one NPC attacker (no character_id, no + # corporation_id), two pilots from the same corporation (so corp + # deduplication is exercised), and a third pilot from another corp who lands + # the final blow while a *different* pilot deals the most damage. + defp nested_kill do + %{ + "killmail_id" => 120_345_678, + "kill_time" => "2026-08-03T14:22:31Z", + "solar_system_id" => 31_000_005, + "victim" => %{ + "character_id" => 95_465_499, + "character_name" => "Victim Pilot", + "corporation_id" => 98_000_001, + "corporation_ticker" => "VCTM", + "corporation_name" => "Victim Corp", + "alliance_id" => 99_000_001, + "alliance_ticker" => "VALL", + "alliance_name" => "Victim Alliance", + "ship_type_id" => 670, + "ship_name" => "Capsule" + }, + "attackers" => [ + %{ + "character_id" => nil, + "corporation_id" => nil, + "damage_done" => 120, + "final_blow" => false, + "ship_type_id" => 30_889, + "ship_name" => "Sleepless Sentinel" + }, + %{ + "character_id" => 91_000_001, + "character_name" => "Top Damage Pilot", + "corporation_id" => 98_100_001, + "corporation_ticker" => "TDMG", + "corporation_name" => "Top Damage Corp", + "alliance_id" => 99_100_001, + "alliance_ticker" => "TALL", + "alliance_name" => "Top Alliance", + "damage_done" => 9_500, + "final_blow" => false, + "ship_type_id" => 11_567, + "ship_name" => "Avatar" + }, + %{ + "character_id" => 91_000_002, + "character_name" => "Same Corp Pilot", + "corporation_id" => 98_100_001, + "corporation_ticker" => "TDMG", + "corporation_name" => "Top Damage Corp", + "damage_done" => 400, + "final_blow" => false, + "ship_type_id" => 587, + "ship_name" => "Rifter" + }, + %{ + "character_id" => 91_000_003, + "character_name" => "Final Blow Pilot", + "corporation_id" => 98_100_002, + "corporation_ticker" => "FBLW", + "corporation_name" => "Final Blow Corp", + "damage_done" => 1_200, + "final_blow" => true, + "ship_type_id" => 621, + "ship_name" => "Caracal" + } + ], + "zkb" => %{"total_value" => 1_234_567.0, "npc" => false} + } + end + + describe "attacker id lists" do + test "collects attacker character ids, rejecting NPC nils" do + assert {:ok, kill} = MessageHandler.adapt_kill_data(nested_kill()) + + assert kill["attacker_char_ids"] == [91_000_001, 91_000_002, 91_000_003] + end + + test "collects attacker corporation ids, rejecting nils and deduplicating" do + assert {:ok, kill} = MessageHandler.adapt_kill_data(nested_kill()) + + assert kill["attacker_corp_ids"] == [98_100_001, 98_100_002] + end + + test "an all-NPC kill yields empty lists rather than missing keys" do + npc_kill = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => nil, + "corporation_id" => nil, + "damage_done" => 500, + "final_blow" => true, + "ship_type_id" => 30_889 + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(npc_kill) + + assert kill["attacker_char_ids"] == [] + assert kill["attacker_corp_ids"] == [] + end + + test "attacker_count still reflects every attacker, NPCs included" do + assert {:ok, kill} = MessageHandler.adapt_kill_data(nested_kill()) + + assert kill["attacker_count"] == 4 + end + + test "a string character_id on the wire yields an integer in attacker_char_ids" do + string_id_kill = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => "91000001", + "character_name" => "String Id Pilot", + "corporation_id" => "98100001", + "damage_done" => 500, + "final_blow" => true, + "ship_type_id" => 587 + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(string_id_kill) + + # Discord.Matcher's tracked-pilot set is built from integers; a binary + # here would silently fail to ever match it. + assert kill["attacker_char_ids"] == [91_000_001] + assert kill["attacker_corp_ids"] == [98_100_001] + assert Enum.all?(kill["attacker_char_ids"], &is_integer/1) + end + end + + describe "top damage attacker" do + test "selects the highest-damage attacker when it differs from the final blow" do + assert {:ok, kill} = MessageHandler.adapt_kill_data(nested_kill()) + + # Top damage: 9_500. Final blow: a different pilot with 1_200. + assert kill["top_damage_char_id"] == 91_000_001 + assert kill["top_damage_char_name"] == "Top Damage Pilot" + assert kill["top_damage_corp_id"] == 98_100_001 + assert kill["top_damage_corp_ticker"] == "TDMG" + + assert kill["final_blow_char_id"] == 91_000_003 + end + + test "still populates the fields when top damage is the final blow attacker" do + solo = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => 91_000_003, + "character_name" => "Final Blow Pilot", + "corporation_id" => 98_100_002, + "corporation_ticker" => "FBLW", + "corporation_name" => "Final Blow Corp", + "damage_done" => 8_000, + "final_blow" => true, + "ship_type_id" => 621, + "ship_name" => "Caracal" + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(solo) + + assert kill["top_damage_char_id"] == 91_000_003 + assert kill["top_damage_char_name"] == "Final Blow Pilot" + assert kill["final_blow_char_id"] == 91_000_003 + end + + test "an all-NPC kill has nil top damage pilot fields but is still valid" do + npc_kill = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => nil, + "corporation_id" => nil, + "damage_done" => 500, + "final_blow" => true, + "ship_type_id" => 30_889 + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(npc_kill) + + # Not just nil-valued — genuinely absent would also read as nil via + # `kill["top_damage_char_id"]`, so this test cannot fail on a deleted + # `add_attacker_identity_data/2` stage without also asserting presence. + assert Map.has_key?(kill, "top_damage_char_id") + assert Map.has_key?(kill, "top_damage_char_name") + assert Map.has_key?(kill, "top_damage_corp_id") + assert Map.has_key?(kill, "top_damage_corp_ticker") + + assert kill["top_damage_char_id"] == nil + assert kill["top_damage_char_name"] == nil + assert kill["top_damage_corp_id"] == nil + assert kill["top_damage_corp_ticker"] == nil + end + + test "attackers with no damage_done key do not crash selection" do + no_damage = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => 91_000_010, + "character_name" => "No Damage Key", + "corporation_id" => 98_100_010, + "corporation_ticker" => "NDMG", + "final_blow" => true, + "ship_type_id" => 587 + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(no_damage) + + assert kill["top_damage_char_id"] == 91_000_010 + end + + test "an attacker with damage_done beats one missing the key entirely" do + # A single-element list proves nothing about comparator behavior + # (max_by returns the only element regardless). This pins that a + # present `damage_done` outranks a missing one, which `damage_done/1` + # treats as 0. + mixed = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => 91_000_020, + "character_name" => "No Damage Key", + "corporation_id" => 98_100_020, + "final_blow" => false, + "ship_type_id" => 587 + }, + %{ + "character_id" => 91_000_021, + "character_name" => "Has Damage", + "corporation_id" => 98_100_021, + "damage_done" => 100, + "final_blow" => true, + "ship_type_id" => 621 + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(mixed) + + assert kill["top_damage_char_id"] == 91_000_021 + end + + test "ties on highest damage resolve to the first attacker in the list" do + # Pins current `Enum.max_by/3` tie-breaking behavior so a future + # rewrite (e.g. switching comparators, or a sort-based reimplementation) + # cannot silently flip which tied pilot gets selected. + tied = + Map.put(nested_kill(), "attackers", [ + %{ + "character_id" => 91_000_030, + "character_name" => "First Tied Pilot", + "corporation_id" => 98_100_030, + "damage_done" => 5_000, + "final_blow" => false, + "ship_type_id" => 621 + }, + %{ + "character_id" => 91_000_031, + "character_name" => "Second Tied Pilot", + "corporation_id" => 98_100_031, + "damage_done" => 5_000, + "final_blow" => true, + "ship_type_id" => 587 + } + ]) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(tied) + + assert kill["top_damage_char_id"] == 91_000_030 + end + end + + describe "absent or malformed attackers list" do + test "no attackers key at all leaves all six identity keys absent" do + no_attackers_key = Map.delete(nested_kill(), "attackers") + + assert {:ok, kill} = MessageHandler.adapt_kill_data(no_attackers_key) + + # Absent means "we don't know who attacked" — different from a payload + # that carried an empty attackers list (see the test below). + refute Map.has_key?(kill, "attacker_char_ids") + refute Map.has_key?(kill, "attacker_corp_ids") + refute Map.has_key?(kill, "top_damage_char_id") + refute Map.has_key?(kill, "top_damage_corp_id") + # The name/ticker keys come from the same merge and are just as much + # part of "we never captured attacker data" — asserting only the ids + # would leave a partial merge undetected. + refute Map.has_key?(kill, "top_damage_char_name") + refute Map.has_key?(kill, "top_damage_corp_ticker") + + # Statistics are still populated from the coerced empty list — that + # part is unaffected by this task. + assert kill["attacker_count"] == 0 + end + + test "a non-list attackers value leaves all six identity keys absent" do + malformed = Map.put(nested_kill(), "attackers", "not a list") + + assert {:ok, kill} = MessageHandler.adapt_kill_data(malformed) + + refute Map.has_key?(kill, "attacker_char_ids") + refute Map.has_key?(kill, "attacker_corp_ids") + refute Map.has_key?(kill, "top_damage_char_id") + refute Map.has_key?(kill, "top_damage_corp_id") + refute Map.has_key?(kill, "top_damage_char_name") + refute Map.has_key?(kill, "top_damage_corp_ticker") + end + + test "a genuinely empty attackers list produces present, empty identity data" do + empty_list = Map.put(nested_kill(), "attackers", []) + + assert {:ok, kill} = MessageHandler.adapt_kill_data(empty_list) + + # Present and empty ("we looked, there were no attackers") must be + # distinguishable from absent ("we never captured attacker data") — + # this is the opposite assertion of the two tests above. + assert Map.has_key?(kill, "attacker_char_ids") + assert Map.has_key?(kill, "attacker_corp_ids") + assert kill["attacker_char_ids"] == [] + assert kill["attacker_corp_ids"] == [] + assert kill["top_damage_char_id"] == nil + assert Map.has_key?(kill, "top_damage_char_id") + end + end + + describe "already-flat payloads" do + test "pass through without the new keys — absent, not empty" do + flat = %{ + "killmail_id" => 120_345_679, + "kill_time" => "2026-08-03T14:25:00Z", + "solar_system_id" => 31_000_005, + "victim_char_id" => 95_465_499, + "victim_corp_id" => 98_000_001, + "victim_ship_type_id" => 670, + "attacker_count" => 4, + "total_value" => 1_234_567.0 + } + + assert {:ok, kill} = MessageHandler.adapt_kill_data(flat) + + # Task 7 relies on absence meaning "unknown". Defaulting these to [] + # here would silently disable all attacker matching for flat payloads. + refute Map.has_key?(kill, "attacker_char_ids") + refute Map.has_key?(kill, "attacker_corp_ids") + refute Map.has_key?(kill, "top_damage_char_id") + refute Map.has_key?(kill, "top_damage_corp_id") + refute Map.has_key?(kill, "top_damage_char_name") + refute Map.has_key?(kill, "top_damage_corp_ticker") + end + + test "a flat payload that already carries the new keys keeps them" do + flat = %{ + "killmail_id" => 120_345_680, + "kill_time" => "2026-08-03T14:26:00Z", + "solar_system_id" => 31_000_005, + "victim_char_id" => 95_465_499, + "victim_corp_id" => 98_000_001, + "attacker_char_ids" => [91_000_001], + "attacker_corp_ids" => [98_100_001] + } + + assert {:ok, kill} = MessageHandler.adapt_kill_data(flat) + + assert kill["attacker_char_ids"] == [91_000_001] + assert kill["attacker_corp_ids"] == [98_100_001] + end + end +end diff --git a/test/unit/system_class_test.exs b/test/unit/system_class_test.exs new file mode 100644 index 000000000..8c537d5bd --- /dev/null +++ b/test/unit/system_class_test.exs @@ -0,0 +1,76 @@ +defmodule WandererApp.SystemClassTest do + use ExUnit.Case, async: true + + alias WandererApp.SystemClass + + describe "wormhole?/1" do + test "returns true for c1-c6" do + for class <- 1..6 do + assert SystemClass.wormhole?(class), "class #{class} should be wormhole" + end + end + + test "returns true for thera and c13" do + assert SystemClass.wormhole?(12) + assert SystemClass.wormhole?(13) + end + + test "returns true for drifter holes" do + for class <- 14..18 do + assert SystemClass.wormhole?(class), "class #{class} should be wormhole" + end + end + + test "returns false for known space" do + refute SystemClass.wormhole?(7) + refute SystemClass.wormhole?(8) + refute SystemClass.wormhole?(9) + end + + test "returns false for pochven and zarzakh" do + refute SystemClass.wormhole?(25) + refute SystemClass.wormhole?(10_100) + end + + test "returns false for nil and unknown classes" do + refute SystemClass.wormhole?(nil) + refute SystemClass.wormhole?(999) + end + end +end + +defmodule WandererAppWeb.KillmailFactoryTest do + use ExUnit.Case, async: true + + alias WandererAppWeb.Factory + + test "build(:killmail) produces string keys with required fields" do + kill = Factory.build(:killmail) + + assert is_integer(kill["killmail_id"]) + assert is_binary(kill["kill_time"]) + assert is_integer(kill["solar_system_id"]) + assert kill["total_value"] == 84_000_000 + end + + test "build(:killmail) accepts atom-key overrides" do + kill = Factory.build(:killmail, %{victim_ship_name: nil, total_value: 0}) + + assert kill["victim_ship_name"] == nil + assert kill["total_value"] == 0 + end + + test "build(:kill_event) wraps killmails in the batch shape" do + event = Factory.build(:kill_event) + + assert event["type"] == :killmail_update + assert [%{"killmail_id" => _}] = event["killmails"] + end + + test "build(:kill_count_event) has no killmails" do + event = Factory.build(:kill_count_event) + + assert event["type"] == :kill_count + refute Map.has_key?(event, "killmails") + end +end From fad3f0654961e607d977c75f48dc70621394b3a5 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sat, 8 Aug 2026 17:59:18 +0000 Subject: [PATCH 16/94] zoo(feat): add the discord notification settings UI --- .../MarkdownEditor/MarkdownEditor.tsx | 1 - lib/wanderer_app/map/README.md | 23 +- .../components/map_notifications_component.ex | 836 ++++++++++++++++++ .../live/maps/maps_live.html.heex | 35 + .../2026/08-02-discord-kill-notifications.md | 152 ++++ .../live/map_notifications_test.exs | 759 ++++++++++++++++ 6 files changed, 1801 insertions(+), 5 deletions(-) create mode 100644 lib/wanderer_app_web/live/maps/components/map_notifications_component.ex create mode 100644 priv/posts/2026/08-02-discord-kill-notifications.md create mode 100644 test/wanderer_app_web/live/map_notifications_test.exs diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx b/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx index 688250378..1d4d8a3ae 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx @@ -83,7 +83,6 @@ export const MarkdownEditor = ({ onChange={handleOnChange} placeholder="Start typing..." readOnly={readOnly} - editable={!readOnly} />
rec + _ -> nil + end + + {:ok, + socket + |> assign(assigns) + |> assign(:excluded_select_id, @excluded_select_id) + |> assign(:focus_corp_select_id, @focus_corp_select_id) + |> assign(:min_search_length, @min_search_length) + |> assign(:corp_min_search_length, CorporationSearch.min_search_length()) + |> assign_new(:system_options, fn -> [] end) + |> assign_new(:corp_options, fn -> [] end) + |> assign_new(:error, fn -> nil end) + |> assign_new(:flash_message, fn -> nil end) + |> assign_notification(notification)} + end + + @impl true + def handle_event("save", %{"notification" => params}, socket) do + # `.input type="checkbox"` renders a hidden "false" before the box, so a + # rendered field always submits a value and Phoenix keeps the last one. + attrs = %{ + wh_only: checked?(params["wh_only"]), + enabled?: checked?(params["enabled"]) + } + + result = + case socket.assigns.notification do + nil -> + create_with_system_webhook(socket.assigns.map_id, attrs, params["webhook_url"]) + + rec -> + # Deliberately NOT `webhook_url`: that moved to the child resource and + # is no longer an accepted input here — passing it raises NoSuchInput + # at runtime. URLs are saved through "save-webhook". + MapDiscordNotification.update(rec, attrs) + end + + case result do + {:ok, rec} -> + {:noreply, + socket + |> assign_notification(rec) + |> assign(:error, nil) + |> assign(:flash_message, "Saved.")} + + {:error, error} -> + {:noreply, socket |> assign(:error, humanize_error(error)) |> assign(:flash_message, nil)} + end + end + + def handle_event("replace-url", %{"role" => role}, socket) do + {:noreply, put_replacing(socket, parse_role(role), true)} + end + + def handle_event("save-webhook", %{"role" => role, "webhook" => params}, socket) do + role = parse_role(role) + + with %{} = rec <- socket.assigns.notification, + {:ok, _} <- save_webhook(rec, socket.assigns.webhooks[role], role, params) do + {:noreply, + socket + |> assign_notification(reload_notification(socket.assigns.map_id)) + |> assign(:error, nil) + |> assign(:flash_message, "Saved.")} + else + {:error, error} -> + {:noreply, socket |> assign(:error, humanize_error(error)) |> assign(:flash_message, nil)} + + _ -> + {:noreply, assign(socket, :error, "Save the map's notification settings first.")} + end + end + + def handle_event("remove-webhook", %{"role" => role}, socket) do + # Only the `:character` destination is removable. `:system` is required — + # removing notifications entirely means deleting the parent record. + role = parse_role(role) + + case {role, socket.assigns.webhooks[role]} do + {:character, %{} = webhook} -> + case MapDiscordWebhook.destroy(webhook) do + :ok -> + {:noreply, + socket + |> assign_notification(reload_notification(socket.assigns.map_id)) + |> assign(:error, nil) + |> assign(:flash_message, "Character destination removed.")} + + {:error, error} -> + {:noreply, + socket |> assign(:error, humanize_error(error)) |> assign(:flash_message, nil)} + end + + _ -> + {:noreply, assign(socket, :error, "The system destination cannot be removed.")} + end + end + + # LiveSelect's search callback: users know systems and corporations by name, + # not by numeric id. + # + # This must be handled HERE and not by the parent LiveView, whose own + # `live_select_change` handler answers unconditionally with access-list + # options. `phx-target={@myself}` on each live_select is what keeps the event + # in this component. + # + # Two pickers now share this handler, so it MUST dispatch on the id that + # fired. Answering unconditionally with system options would fill the + # corporation dropdown with solar systems. + def handle_event("live_select_change", %{"id" => @focus_corp_select_id, "text" => text}, socket) do + options = search_corporations(socket.assigns[:current_user], text) + + send_update(LiveSelect.Component, id: @focus_corp_select_id, options: options) + + {:noreply, assign(socket, :corp_options, options)} + end + + def handle_event("live_select_change", %{"id" => id, "text" => text}, socket) do + options = search_systems(text) + + send_update(LiveSelect.Component, id: id, options: options) + + {:noreply, assign(socket, :system_options, options)} + end + + def handle_event("add-excluded", %{"excluded" => %{"excluded_system" => raw}}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_excluded(socket, rec, Enum.uniq([id | rec.excluded_systems])) + else + _ -> {:noreply, assign(socket, :error, "Pick a system from the list.")} + end + end + + # Guarded the same way as `add-excluded`: only reachable from a rendered + # button today, but the two handlers should not disagree about whether a + # missing record or a non-numeric id is survivable. + def handle_event("remove-excluded", %{"system_id" => raw}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_excluded(socket, rec, Enum.reject(rec.excluded_systems, &(&1 == id))) + else + _ -> {:noreply, assign(socket, :error, "Could not remove that system.")} + end + end + + # Guarded the same way as `add-excluded`: only reachable from a rendered + # record, and only for an id that parses cleanly. LiveSelect hands back the + # corporation eve id as a STRING (`character.ex:365`), while `focus_corp_ids` + # stores integers. + def handle_event("add-focus-corp", %{"focus_corp" => %{"focus_corp" => raw}}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_focus_corps(socket, rec, Enum.uniq(rec.focus_corp_ids ++ [id])) + else + _ -> {:noreply, assign(socket, :error, "Pick a corporation from the list.")} + end + end + + def handle_event("remove-focus-corp", %{"corp_id" => raw}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_focus_corps(socket, rec, Enum.reject(rec.focus_corp_ids, &(&1 == id))) + else + _ -> {:noreply, assign(socket, :error, "Could not remove that corporation.")} + end + end + + def handle_event("send-test", %{"webhook_id" => webhook_id}, socket) do + case send_test(socket, webhook_id) do + # `:ok` means the message was ENQUEUED — the last hop is an async cast, so + # this must not claim Discord accepted it. + :ok -> + {:noreply, + socket |> assign(:flash_message, "Test message queued.") |> assign(:error, nil)} + + {:error, :notifications_disabled} -> + {:noreply, + socket + |> assign( + :error, + "Discord notifications are disabled on this server. Ask an administrator to enable them." + ) + |> assign(:flash_message, nil)} + + {:error, :webhook_disabled} -> + {:noreply, + socket + |> assign( + :error, + "This destination is disabled. Enable it and save before sending a test message." + ) + |> assign(:flash_message, nil)} + + # Two distinct dispatcher answers, one message on purpose: "no such row" + # and "row with no usable URL" are worth telling apart in a log or a test, + # but a user can do nothing about either except save a URL, and this + # wording is brief-mandated verbatim. + {:error, reason} when reason in [:webhook_not_found, :webhook_url_missing] -> + {:noreply, + socket |> assign(:error, "Save a webhook URL first.") |> assign(:flash_message, nil)} + + {:error, other} -> + # `inspect(other)` here put the raw failure term — which can carry the + # webhook URL — into the LiveView diff. Log a shape summary instead. + Logger.warning("[MapNotifications] test message failed: #{error_summary(other)}") + + {:noreply, + socket + |> assign(:error, "Could not send a test message. Check the webhook URL and try again.") + |> assign(:flash_message, nil)} + end + end + + def handle_event("delete", _params, socket) do + case socket.assigns.notification do + nil -> + {:noreply, socket} + + rec -> + # The resource's custom destroy invalidates the config cache and stops + # the delivery workers; the webhook children cascade with the parent. + case WandererApp.Api.MapDiscordNotification.destroy(rec) do + :ok -> + {:noreply, + socket + |> assign_notification(nil) + |> assign(:error, nil) + |> assign(:flash_message, "Removed.")} + + {:error, error} -> + {:noreply, + socket |> assign(:error, humanize_error(error)) |> assign(:flash_message, nil)} + end + end + end + + # `webhook_id` arrives from the client as `phx-value-webhook_id`, and + # `send_test_message/1` resolves it by id alone — across every map in the + # installation. So the id MUST be matched against this map's own destinations + # before it is dispatched; without that, anyone who can open a settings tab + # can post the test message into any other map's Discord channel. + # + # The `enabled?` check that rides along STAYS even though `send_test_message/1` + # now reports the disabled case itself, and the ordering is the reason. The + # dispatcher checks the global kill-switch first, so with webhooks disabled + # server-wide it answers `:notifications_disabled` and never looks at the row — + # a user who unticked one destination would be told the whole server is off. + # Checking here keeps the more specific message. `map_notifications_test.exs` + # fails if either half of this is removed. + defp send_test(socket, webhook_id) do + socket.assigns.webhooks + |> Map.values() + |> Enum.find(&match?(%{id: ^webhook_id}, &1)) + |> case do + nil -> {:error, :webhook_not_found} + %{enabled?: false} -> {:error, :webhook_disabled} + _webhook -> WandererApp.ExternalEvents.DiscordDispatcher.send_test_message(webhook_id) + end + end + + defp save_webhook(rec, nil, role, %{"webhook_url" => url}) when is_binary(url) and url != "" do + MapDiscordWebhook.create(%{notification_id: rec.id, role: role, webhook_url: url}) + end + + defp save_webhook(_rec, nil, _role, _params), do: {:error, "Enter a webhook URL first."} + + defp save_webhook(_rec, webhook, _role, %{"webhook_url" => url} = params) + when is_binary(url) and url != "" do + MapDiscordWebhook.update(webhook, %{ + webhook_url: url, + enabled?: checked?(params["enabled"]) + }) + end + + defp save_webhook(_rec, webhook, _role, params) do + MapDiscordWebhook.set_enabled(webhook, %{enabled?: checked?(params["enabled"])}) + end + + # Creating the config and its required `:system` destination is one user + # action. Task 2's `create` takes `webhook_url` as a required argument and + # creates the `:system` child through `manage_relationship` in the SAME + # transaction, so there is no window in which a parent exists without a + # destination. Do not split this into two calls with a compensating cleanup: + # the two-step version fails outright (`create` rejects a missing + # `webhook_url`) and its explicit child create would collide with Task 1's + # (notification_id, role) identity. + defp create_with_system_webhook(map_id, attrs, url) do + attrs + |> Map.put(:map_id, map_id) + |> Map.put(:webhook_url, url) + |> MapDiscordNotification.create() + end + + defp put_replacing(socket, role, value) do + assign(socket, :replacing_url?, Map.put(socket.assigns.replacing_url?, role, value)) + end + + defp parse_role("character"), do: :character + defp parse_role(:character), do: :character + defp parse_role(_), do: :system + + defp reload_notification(map_id) do + case MapDiscordNotification.by_map(map_id) do + {:ok, rec} -> rec + _ -> nil + end + end + + defp update_excluded(socket, rec, excluded) do + case MapDiscordNotification.update(rec, %{excluded_systems: excluded}) do + {:ok, updated} -> + {:noreply, socket |> assign_notification(updated) |> assign(:error, nil)} + + {:error, error} -> + {:noreply, assign(socket, :error, humanize_error(error))} + end + end + + defp update_focus_corps(socket, rec, corp_ids) do + case MapDiscordNotification.update(rec, %{focus_corp_ids: corp_ids}) do + {:ok, updated} -> + {:noreply, socket |> assign_notification(updated) |> assign(:error, nil)} + + {:error, error} -> + {:noreply, assign(socket, :error, humanize_error(error))} + end + end + + # Resolves excluded-system names and focus-corporation labels once per change, + # not once per render: both run lookups, and the template re-renders on every + # live_select keystroke. Also rebuilds every form so values follow the record. + defp assign_notification(socket, notification) do + webhooks = load_webhooks(notification) + + socket + |> assign(:notification, notification) + |> assign(:webhooks, webhooks) + |> assign(:excluded_systems, excluded_system_labels(notification)) + |> assign(:focus_corps, focus_corp_labels(notification)) + |> assign(:form, notification_form(notification)) + |> assign(:webhook_forms, webhook_forms(webhooks)) + |> assign(:excluded_form, to_form(%{"excluded_system" => nil}, as: :excluded)) + |> assign(:focus_corp_form, to_form(%{"focus_corp" => nil}, as: :focus_corp)) + |> assign_replacing(webhooks) + end + + # A destination with no stored URL is always in "replace" (i.e. entry) mode; + # one with a URL starts masked. Recomputed on every record change so that + # saving a URL collapses the field back to the masked hint. + defp assign_replacing(socket, webhooks) do + replacing = + Map.new(@roles, fn role -> + {role, is_nil(Map.get(webhooks, role))} + end) + + assign(socket, :replacing_url?, replacing) + end + + defp load_webhooks(nil), do: %{system: nil, character: nil} + + defp load_webhooks(%{id: notification_id}) do + records = + case MapDiscordWebhook.by_notification(notification_id) do + {:ok, list} -> list + _ -> [] + end + + Map.new(@roles, fn role -> {role, Enum.find(records, &(&1.role == role))} end) + end + + defp notification_form(notification) do + to_form( + %{ + "webhook_url" => "", + "wh_only" => is_nil(notification) or notification.wh_only, + "enabled" => is_nil(notification) or notification.enabled? + }, + as: :notification + ) + end + + defp webhook_forms(webhooks) do + Map.new(@roles, fn role -> + webhook = Map.get(webhooks, role) + + form = + to_form( + %{ + "webhook_url" => "", + "enabled" => is_nil(webhook) or webhook.enabled? + }, + as: :webhook + ) + + {role, form} + end) + end + + # Mirrors the ACL live_select pattern in maps_live: search server-side, feed + # `{label, value}` options back into the component. + defp search_systems(text) when is_binary(text) and byte_size(text) >= @min_search_length do + case MapSolarSystem.find_by_name(%{name: text}) do + {:ok, systems} -> + systems + |> Enum.take(@max_search_results) + |> Enum.map(&{"#{&1.solar_system_name} (#{&1.region_name})", &1.solar_system_id}) + + _ -> + [] + end + end + + defp search_systems(_), do: [] + + # `CorporationSearch.search/2` enforces its own minimum length and returns + # `{:ok, []}` for a user with no characters, so no length guard is needed here. + # + # The rescue is not belt-and-braces: the search runs as one of the user's + # characters, and a character whose ESI token has never been refreshed makes + # `Character.search/2` raise. Unrescued that kills the LiveView on a keystroke + # in the corporation box, taking the whole settings tab with it. An empty + # dropdown is the right degradation for a lookup this component cannot fix. + defp search_corporations(%{characters: characters}, text) when is_list(characters) do + case CorporationSearch.search(characters, text) do + {:ok, results} -> + results + |> Enum.take(@max_search_results) + |> Enum.map(&{&1.formatted, &1.id}) + + _ -> + [] + end + rescue + error -> + Logger.warning("[MapNotifications] corporation search failed: #{inspect(error)}") + [] + end + + defp search_corporations(_current_user, _text), do: [] + + # One query for every excluded system, not one per system. Falls back to the + # bare id for anything the lookup did not return, and keeps the stored order. + defp excluded_system_labels(nil), do: [] + defp excluded_system_labels(%{excluded_systems: []}), do: [] + + defp excluded_system_labels(%{excluded_systems: ids}) do + labels = + case MapSolarSystem.by_solar_system_ids(ids) do + {:ok, systems} -> + Map.new( + systems, + &{&1.solar_system_id, "#{&1.solar_system_name} (#{&1.solar_system_id})"} + ) + + _ -> + %{} + end + + Enum.map(ids, &{&1, Map.get(labels, &1, to_string(&1))}) + end + + # `label_for/1` already degrades to the bare id, so a chip is never dropped + # because ESI is unreachable — the user must always be able to remove what + # they saved. + defp focus_corp_labels(nil), do: [] + defp focus_corp_labels(%{focus_corp_ids: []}), do: [] + + defp focus_corp_labels(%{focus_corp_ids: ids}), + do: Enum.map(ids, &{&1, CorporationSearch.label_for(&1)}) + + defp checked?("true"), do: true + defp checked?(true), do: true + defp checked?(_), do: false + + defp humanize_error(message) when is_binary(message), do: message + + defp humanize_error(%Ash.Error.Invalid{errors: errors}) do + Enum.map_join(errors, ", ", &error_sentence/1) + end + + defp humanize_error(other), do: fallback_message(other) + + # Ash carries validation copy as a template plus a `vars` bag — a max-length + # violation's `message` is the literal `length must be less than or equal to + # %{max}`. Rendering the raw field shows the user the placeholder, so + # substitute before display. + defp error_sentence(%{message: message} = error) when is_binary(message) do + error + |> Map.get(:vars) + |> List.wrap() + |> Enum.reduce(message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", var_string(value)) + end) + end + + defp error_sentence(other), do: fallback_message(other) + + # Anything without a message is an error shape we did not anticipate. Its + # fields are not user-facing copy, so it is logged rather than rendered — but + # only its TYPE. The struct itself must never be inspected into the log: an + # `Ash.Error.Invalid` raised by a create carries the submitted value in + # `InvalidArgument`/`InvalidAttribute`'s `value:` field, and `sensitive? true` + # on the attribute does NOT redact that — so `inspect/1` here would write the + # webhook URL, a credential, into the log in full. The type is enough to + # identify the shape and add a clause for it. + defp fallback_message(error) do + Logger.warning("[MapNotifications] unrecognised error shape: #{error_summary(error)}") + "Something went wrong. Please try again." + end + + # Struct name for structs, the atom itself for atom reasons (those are code + # constants, never user input), and the bare kind for anything else. None of + # these can carry a submitted value. + defp error_summary(%module{}), do: inspect(module) + defp error_summary(error) when is_atom(error), do: inspect(error) + defp error_summary(error) when is_tuple(error), do: "#{tuple_size(error)}-tuple" + defp error_summary(error) when is_list(error), do: "#{length(error)}-element list" + defp error_summary(error) when is_map(error), do: "plain map" + defp error_summary(_error), do: "unrecognised term" + + defp var_string(value) when is_binary(value), do: value + defp var_string(value) when is_number(value) or is_atom(value), do: to_string(value) + defp var_string(value), do: inspect(value) + + defp masked_url(nil), do: "" + + defp masked_url(url) when is_binary(url) do + case String.split(url, "/", trim: true) do + parts when length(parts) >= 2 -> + [token, id | _] = Enum.reverse(parts) + ".../#{id}/#{String.slice(token, 0, 4)}••••" + + _ -> + "••••" + end + end + + defp masked_url(_), do: "••••" + + attr :role, :atom, required: true + attr :title, :string, required: true + attr :help, :string, required: true + attr :webhook, :any, required: true + attr :form, :any, required: true + attr :replacing?, :boolean, required: true + attr :removable?, :boolean, required: true + attr :myself, :any, required: true + + defp webhook_row(assigns) do + ~H""" +
+

{@title}

+

{@help}

+ + <.form + :let={wf} + for={@form} + id={"webhook-form-#{@role}"} + phx-submit="save-webhook" + phx-value-role={@role} + phx-target={@myself} + class="flex flex-col gap-2" + > + <.input + :if={@replacing?} + field={wf[:webhook_url]} + type="password" + label="Discord webhook URL" + placeholder="https://discord.com/api/webhooks/..." + autocomplete="off" + /> + +
+ URL: {masked_url(@webhook.webhook_url)} + <.button type="button" phx-click="replace-url" phx-value-role={@role} phx-target={@myself}> + Replace + +
+ + <.input :if={@webhook} field={wf[:enabled]} type="checkbox" label="Enabled" /> + + <.button type="submit">{if @webhook, do: "Save", else: "Add"} + + +
+ <.button + type="button" + phx-click="send-test" + phx-value-webhook_id={@webhook.id} + phx-target={@myself} + > + Send test message + + <.button + :if={@removable?} + type="button" + class="btn-error" + phx-click="remove-webhook" + phx-value-role={@role} + phx-target={@myself} + data-confirm="Remove this Discord destination?" + > + Remove + +
+ +
+ + Last delivered: {Calendar.strftime(@webhook.last_delivery_at, "%Y-%m-%d %H:%M UTC")} + + + No kills delivered yet. + + + + Last error: {@webhook.last_error} + 0}> + ({@webhook.consecutive_failures} consecutive failures) + + + + This destination is disabled and is not delivering. + +
+
+ """ + end + + @impl true + def render(assigns) do + ~H""" +
+

+ Posts kills to Discord. These filters are separate from the Kills widget's + own filters, which are per-user and only affect what you see in the map UI. +

+ +

{@error}

+

{@flash_message}

+ + <.form + :let={f} + for={@form} + id="discord-notification-form" + phx-submit="save" + phx-target={@myself} + class="flex flex-col gap-3" + > + <.input + :if={is_nil(@notification)} + field={f[:webhook_url]} + type="password" + label="Discord webhook URL (system channel)" + placeholder="https://discord.com/api/webhooks/..." + autocomplete="off" + /> + + <.input field={f[:wh_only]} type="checkbox" label="Only wormhole kills" /> + <.input field={f[:enabled]} type="checkbox" label="Enabled for this map" /> + + <.button type="submit">Save + + + <.webhook_row + :if={@notification} + role={:system} + title="System channel" + help="Receives kills that happen in systems on this map." + webhook={@webhooks[:system]} + form={@webhook_forms[:system]} + replacing?={@replacing_url?[:system]} + removable?={false} + myself={@myself} + /> + + <.webhook_row + :if={@notification} + role={:character} + title="Character channel (optional)" + help={ + "Receives kills involving characters tracked on this map, wherever they happen. " <> + "Leave it unset and those kills go to the system channel instead." + } + webhook={@webhooks[:character]} + form={@webhook_forms[:character]} + replacing?={@replacing_url?[:character]} + removable?={true} + myself={@myself} + /> + +
+

Excluded systems

+ +
    +
  • + {label} + <.button + type="button" + phx-click="remove-excluded" + phx-value-system_id={system_id} + phx-target={@myself} + > + Remove + +
  • +
+ + <.form + :let={ef} + for={@excluded_form} + id="excluded-system-form" + phx-submit="add-excluded" + phx-target={@myself} + class="grid items-end gap-2" + style="grid-template-columns: 1fr auto" + > + <.live_select + field={ef[:excluded_system]} + id={@excluded_select_id} + phx-target={@myself} + dropdown_extra_class="!h-24" + debounce={250} + update_min_len={@min_search_length} + mode={:single} + options={@system_options} + placeholder="Search a system by name" + /> + <.button type="submit">Add + +
+ +
+

Focus corporations

+

+ Kills involving these corporations are treated as relevant even when the + system or wormhole-only filters would otherwise drop them. +

+ +
    +
  • + {label} + <.button + type="button" + phx-click="remove-focus-corp" + phx-value-corp_id={corp_id} + phx-target={@myself} + > + Remove + +
  • +
+ +

+ Add a character to this account to search corporations. +

+ + <.form + :let={cf} + :if={@current_user.characters not in [nil, []]} + for={@focus_corp_form} + id="focus-corp-form" + phx-submit="add-focus-corp" + phx-target={@myself} + class="grid items-end gap-2" + style="grid-template-columns: 1fr auto" + > + <.live_select + field={cf[:focus_corp]} + id={@focus_corp_select_id} + phx-target={@myself} + dropdown_extra_class="!h-24" + debounce={250} + update_min_len={@corp_min_search_length} + mode={:single} + options={@corp_options} + placeholder="Search a corporation by name" + /> + <.button type="submit">Add + +
+ +
+ <.button + type="button" + class="btn-error" + phx-click="delete" + phx-target={@myself} + data-confirm="Remove Discord notifications for this map?" + > + Remove all Discord notifications + +
+
+ """ + end +end diff --git a/lib/wanderer_app_web/live/maps/maps_live.html.heex b/lib/wanderer_app_web/live/maps/maps_live.html.heex index e8c138a9c..64efca426 100644 --- a/lib/wanderer_app_web/live/maps/maps_live.html.heex +++ b/lib/wanderer_app_web/live/maps/maps_live.html.heex @@ -420,6 +420,33 @@ +
@@ -634,6 +661,14 @@ current_user={@current_user} readonly={false} /> + + <.live_component + :if={@active_settings_tab == "notifications"} + module={WandererAppWeb.MapNotificationsComponent} + id="map-notifications" + map_id={@map.id} + current_user={@current_user} + /> diff --git a/priv/posts/2026/08-02-discord-kill-notifications.md b/priv/posts/2026/08-02-discord-kill-notifications.md new file mode 100644 index 000000000..cb3c2a7d8 --- /dev/null +++ b/priv/posts/2026/08-02-discord-kill-notifications.md @@ -0,0 +1,152 @@ +%{ +title: "New Feature: Discord Kill Notifications", +author: "Wanderer Team", +cover_image_uri: "/images/news/08-02-discord-kill-notifications/cover.png", +tags: ~w(discord notifications kills map settings guide), +description: "Post kills from your map straight into a Discord channel. Set a webhook once, filter to wormhole space, exclude the systems you don't care about." +} + +--- + +# Discord Kill Notifications + +Your chain is already telling you where the fights are — the Kills widget shows +every killmail in the systems on your map. The catch is that somebody has to be +looking at it. If the map is on a second monitor nobody is watching, a hostile +gang rolling into your home hole looks exactly like an empty screen. + +So we added a direct line out: **Discord kill notifications**. Point your map at +a Discord webhook and kills in your chain get posted into the channel your +corp is already sitting in. + +## Setting it up + +Open **Map settings → Notifications**. The tab lives inside the map's settings +page, so it is available to whoever can administer the map — in practice the map +owner and anyone granted admin rights over it. + +1. **Create a webhook in Discord.** In your Discord server, open + *Server Settings → Integrations → Webhooks*, create one, pick the channel it + should post to, and copy the webhook URL. +2. **Paste the URL** into the *Discord webhook URL (system channel)* field on + the Notifications tab and hit **Save**. +3. **Optionally add a character channel.** The *Character channel (optional)* + section takes a second webhook. Kills involving the map's tracked characters + go there instead, so you can keep them out of the chain-intel channel. You + can also list **focus corporations** there: a kill involving a character + from one of those corps counts as "yours" even if that character is not + tracked on the map, so the whole corp's kills land in the character channel + rather than the chain-intel one. If no character channel is configured, + these kills simply stay in the system channel — the split is opt-in. +4. **Send a test message** to confirm the wiring before you rely on it. The + button is right there under the form. + +That is the whole setup. From that point on, kills detected in systems on the +map are formatted and pushed to the channel. + +## What a notification looks like + +Each kill arrives as a Discord embed: + +- **Title** — who lost what ("Some Pilot lost a Loki"), linking straight to the + killmail on zKillboard. +- **System**, **Value**, **Final blow**, **Corp**, and **Alliance** fields. The + final-blow field shows the number of other attackers, so `Some Pilot (+11)` + tells you at a glance whether this was a solo gank or a fleet. +- A **ship render** thumbnail and the victim's corp ticker in the footer. + +Fields that we have no data for are simply left out rather than posted as +"Unknown", so the embed stays readable. + +When a burst of kills lands at once — a fleet fight, a gate camp working through +a convoy — the embeds are batched into as few messages as Discord allows, and a +very large batch is capped with a "…and N more kills not shown." line rather +than flooding your channel with a hundred separate posts. + +## Filters + +Two controls, both on the Notifications tab: + +- **Only wormhole kills** (on by default). Restricts notifications to J-space, + including Thera, shattered systems, and the drifter holes. Turn it off if you + want kills from every system on the map, k-space included. +- **Excluded systems.** Search a system by name and add it to the list. Kills + there are skipped. This is the one to use for your home system if you would + rather not get a ping every time somebody shoots a structure, or for a + highway system that generates constant noise. + +There is also an **Enabled** checkbox, so you can mute the feed without +throwing away the webhook and its filter list. + +**Both filters have a deliberate carve-out:** they do not apply to a kill that +involves one of your own pilots — a tracked character on the map, or a member +of a focus corporation. A kill involving your people is interesting wherever it +happened, so it is still delivered even from an excluded system, and even from +k-space with *Only wormhole kills* left on. Those kills go to the character +channel when one is configured, and to the system channel otherwise. If you +want a system genuinely silent, the **Enabled** checkbox is the control that +covers everything. + +**One thing worth being clear about:** these filters are *not* the same as the +Kills widget filters. The widget's filters are per-user and only change what +*you* see in the map UI. The Discord filters are per-map and server-side — they +apply to everyone in the channel. The two look similar and are deliberately +kept separate. + +## About the webhook URL + +A Discord webhook URL is a credential: anyone holding it can post to your +channel. So we treat it like one. + +- It is **stored encrypted** in the database. +- After you save it, it is never displayed in full again — the settings tab + shows a masked hint like `.../123456/AbCd••••`. +- To point the map at a different channel, click **Replace** and paste the new + URL. There is no way to read the old one back out of the UI. + +If a webhook is deleted on the Discord side, Discord answers with a 404 and that +destination is disabled automatically — no point retrying a channel that no +longer exists. Each destination carries its own enabled flag and health state, +so disabling the character channel this way leaves the system channel posting +normally, and vice versa. Other transient errors (rate limits, brief outages) +are retried with backoff for a bounded number of attempts, and only a sustained +run of failures will disable a destination. Those retries all happen inside the +one delivery attempt — once the attempts are exhausted, the message is dropped +rather than re-queued, which is what keeps delivery at most once (see *Known +limits* below). + +## Self-hosting notes + +Wanderer CE runs this behind the same switch as the rest of the outbound events +system: + +```bash +export WANDERER_WEBHOOKS_ENABLED="true" +``` + +With it off, the Notifications tab still renders but "Send test message" will +tell you notifications are disabled on this server. + +Delivery uses its own isolated connection pool, so a slow Discord cannot back up +the rest of the application. If you run a large instance with many maps sending +notifications, you can size that pool: + +```bash +export WANDERER_DISCORD_POOL_SIZE="10" # default +``` + +## Known limits + +Worth knowing before you wire it into an intel channel: + +- Notifications are **at most once**. If a delivery fails outright, that kill is + not re-sent later. We would rather drop the occasional kill than double-post + into a chat channel, and a dropped kill is still visible in the Kills widget + and on zKillboard. +- Deduplication is in memory, so a restart of the application can let a kill + that was already posted be posted once more. +- Two channels per map: one for system kills, one for character kills. Splitting + further than that — a channel per region, per corp, per anything else — is not + supported yet. + +Fly safe. o7 diff --git a/test/wanderer_app_web/live/map_notifications_test.exs b/test/wanderer_app_web/live/map_notifications_test.exs new file mode 100644 index 000000000..174db290d --- /dev/null +++ b/test/wanderer_app_web/live/map_notifications_test.exs @@ -0,0 +1,759 @@ +defmodule WandererAppWeb.MapNotificationsTest do + use WandererAppWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import ExUnit.CaptureLog + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererAppWeb.Factory + + setup %{conn: conn} do + # `Api.Map.owner_id` points at a CHARACTER, not a user, and + # `Factory.create_map/1` passes `owner_id` straight through. Passing a user + # id here would fail the foreign key. + user = Factory.insert(:user, %{}) + character = Factory.insert(:character, %{user_id: user.id}) + map = Factory.insert(:map, %{owner_id: character.id}) + + %{conn: log_in_user(conn, user), map: map, user: user, character: character} + end + + # The app has no `log_in_user/2` test helper: `UserAuth.on_mount/4` reads + # `session["user_id"]` directly, so seeding the test session is enough. + defp log_in_user(conn, user) do + conn + |> Plug.Test.init_test_session(%{}) + |> Plug.Conn.put_session(:user_id, user.id) + end + + defp open_notifications(conn, map) do + {:ok, view, _html} = live(conn, ~p"/maps/#{map.slug}/settings") + view |> element("[phx-value-tab='notifications']") |> render_click() + view + end + + # Task 2's `create` takes `webhook_url` as a required argument and seeds the + # `:system` child in the same transaction, so `roles` here only controls + # whether a `:character` row is added on top. Passing `:system` in `roles` + # would violate the (notification_id, role) identity from Task 1. + defp notification_with_webhooks(map, roles) do + {:ok, rec} = + MapDiscordNotification.create(%{ + map_id: map.id, + webhook_url: "https://discord.com/api/webhooks/#{:erlang.unique_integer([:positive])}/tok" + }) + + for role <- roles, role != :system do + {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: role, + webhook_url: + "https://discord.com/api/webhooks/#{:erlang.unique_integer([:positive])}/tok" + }) + end + + rec + end + + defp system_webhook(rec) do + {:ok, webhooks} = MapDiscordWebhook.by_notification(rec.id) + Enum.find(webhooks, &(&1.role == :system)) + end + + test "owner sees the notifications tab", %{conn: conn, map: map} do + {:ok, view, _html} = live(conn, ~p"/maps/#{map.slug}/settings") + + assert has_element?(view, "[phx-value-tab='notifications']") + end + + test "saving a valid webhook url creates the record", %{conn: conn, map: map} do + view = open_notifications(conn, map) + + view + |> form("#discord-notification-form", %{ + "notification" => %{ + "webhook_url" => "https://discord.com/api/webhooks/123/tok", + "wh_only" => "true", + "enabled" => "true" + } + }) + |> render_submit() + + assert {:ok, rec} = MapDiscordNotification.by_map(map.id) + assert rec.wh_only == true + # Regression guard: the Enabled checkbox must render during creation too. + # When it was hidden behind `:if={@notification}` the param was absent, so + # `params["enabled"] == "true"` was false and every new config was born + # disabled — invisibly, because the UI showed no checkbox to contradict it. + assert rec.enabled? == true + + # The create action seeds the required `:system` destination in the same + # transaction; a parent with no destination would deliver nothing. + assert {:ok, [webhook]} = MapDiscordWebhook.by_notification(rec.id) + assert webhook.role == :system + end + + test "a new configuration is enabled when the box is left checked", %{conn: conn, map: map} do + view = open_notifications(conn, map) + + # Submit exactly what the browser sends for a checked box rendered with a + # preceding hidden "false": both keys, last one winning. + view + |> form("#discord-notification-form", %{ + "notification" => %{"webhook_url" => "https://discord.com/api/webhooks/123/tok"} + }) + |> render_submit() + + assert {:ok, rec} = MapDiscordNotification.by_map(map.id) + assert rec.enabled? == true + end + + test "an invalid url is rejected with a message", %{conn: conn, map: map} do + view = open_notifications(conn, map) + + html = + view + |> form("#discord-notification-form", %{ + "notification" => %{"webhook_url" => "https://evil.example.com/x"} + }) + |> render_submit() + + # Assert the resource's actual validation message, NOT the string + # "Discord webhook URL" — that is the create form's own