From 981ce62367444ec9da3a1f1aac46c26268675c94 Mon Sep 17 00:00:00 2001 From: Morgan Evans Date: Fri, 18 Sep 2026 11:11:14 +0100 Subject: [PATCH 1/3] Added Lichess plugin v1 --- plugins/Lichess/v1/configValidation.json | 11 + plugins/Lichess/v1/custom_types.json | 16 + .../Lichess/v1/dataStreams/currentUser.json | 19 ++ .../v1/dataStreams/playerActivity.json | 23 ++ .../Lichess/v1/dataStreams/playerProfile.json | 31 ++ .../v1/dataStreams/playerRatingHistory.json | 19 ++ .../v1/dataStreams/playerRecentGames.json | 34 ++ plugins/Lichess/v1/dataStreams/players.json | 19 ++ .../v1/dataStreams/scripts/playerActivity.js | 25 ++ .../v1/dataStreams/scripts/playerProfile.js | 23 ++ .../scripts/playerRatingHistory.js | 17 + .../dataStreams/scripts/playerRecentGames.js | 32 ++ .../Lichess/v1/dataStreams/scripts/players.js | 20 ++ .../v1/dataStreams/scripts/teamMembers.js | 8 + .../Lichess/v1/dataStreams/teamMembers.json | 21 ++ plugins/Lichess/v1/dataStreams/teams.json | 21 ++ .../Lichess/v1/defaultContent/manifest.json | 7 + .../v1/defaultContent/overview.dash.json | 176 +++++++++++ .../v1/defaultContent/player.dash.json | 294 ++++++++++++++++++ plugins/Lichess/v1/defaultContent/scopes.json | 26 ++ .../Lichess/v1/defaultContent/team.dash.json | 88 ++++++ plugins/Lichess/v1/docs/README.md | 56 ++++ plugins/Lichess/v1/icon.svg | 6 + .../Lichess/v1/indexDefinitions/default.json | 46 +++ plugins/Lichess/v1/metadata.json | 41 +++ plugins/Lichess/v1/ui.json | 18 ++ 26 files changed, 1097 insertions(+) create mode 100644 plugins/Lichess/v1/configValidation.json create mode 100644 plugins/Lichess/v1/custom_types.json create mode 100644 plugins/Lichess/v1/dataStreams/currentUser.json create mode 100644 plugins/Lichess/v1/dataStreams/playerActivity.json create mode 100644 plugins/Lichess/v1/dataStreams/playerProfile.json create mode 100644 plugins/Lichess/v1/dataStreams/playerRatingHistory.json create mode 100644 plugins/Lichess/v1/dataStreams/playerRecentGames.json create mode 100644 plugins/Lichess/v1/dataStreams/players.json create mode 100644 plugins/Lichess/v1/dataStreams/scripts/playerActivity.js create mode 100644 plugins/Lichess/v1/dataStreams/scripts/playerProfile.js create mode 100644 plugins/Lichess/v1/dataStreams/scripts/playerRatingHistory.js create mode 100644 plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js create mode 100644 plugins/Lichess/v1/dataStreams/scripts/players.js create mode 100644 plugins/Lichess/v1/dataStreams/scripts/teamMembers.js create mode 100644 plugins/Lichess/v1/dataStreams/teamMembers.json create mode 100644 plugins/Lichess/v1/dataStreams/teams.json create mode 100644 plugins/Lichess/v1/defaultContent/manifest.json create mode 100644 plugins/Lichess/v1/defaultContent/overview.dash.json create mode 100644 plugins/Lichess/v1/defaultContent/player.dash.json create mode 100644 plugins/Lichess/v1/defaultContent/scopes.json create mode 100644 plugins/Lichess/v1/defaultContent/team.dash.json create mode 100644 plugins/Lichess/v1/docs/README.md create mode 100644 plugins/Lichess/v1/icon.svg create mode 100644 plugins/Lichess/v1/indexDefinitions/default.json create mode 100644 plugins/Lichess/v1/metadata.json create mode 100644 plugins/Lichess/v1/ui.json diff --git a/plugins/Lichess/v1/configValidation.json b/plugins/Lichess/v1/configValidation.json new file mode 100644 index 00000000..ccbe4bdd --- /dev/null +++ b/plugins/Lichess/v1/configValidation.json @@ -0,0 +1,11 @@ +{ + "steps": [ + { + "displayName": "Authenticate", + "dataStream": { "name": "currentUser" }, + "required": true, + "error": "Could not authenticate. Check your API token is valid and hasn't expired.", + "success": "Connected successfully." + } + ] +} diff --git a/plugins/Lichess/v1/custom_types.json b/plugins/Lichess/v1/custom_types.json new file mode 100644 index 00000000..d84eee54 --- /dev/null +++ b/plugins/Lichess/v1/custom_types.json @@ -0,0 +1,16 @@ +[ + { + "name": "Lichess Player", + "sourceType": "Lichess Player", + "icon": "chess-knight", + "singular": "Player", + "plural": "Players" + }, + { + "name": "Lichess Team", + "sourceType": "Lichess Team", + "icon": "people-group", + "singular": "Team", + "plural": "Teams" + } +] diff --git a/plugins/Lichess/v1/dataStreams/currentUser.json b/plugins/Lichess/v1/dataStreams/currentUser.json new file mode 100644 index 00000000..3a94204e --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/currentUser.json @@ -0,0 +1,19 @@ +{ + "name": "currentUser", + "displayName": "Current User", + "description": "The authenticated Lichess account's own profile", + "tags": ["Account"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/account" + }, + "matches": "none", + "visibility": { "type": "hidden" }, + "metadata": [ + { "name": "id", "displayName": "ID", "shape": "string" }, + { "name": "username", "displayName": "Username", "shape": "string", "role": "label" }, + { "pattern": ".*" } + ], + "timeframes": false +} diff --git a/plugins/Lichess/v1/dataStreams/playerActivity.json b/plugins/Lichess/v1/dataStreams/playerActivity.json new file mode 100644 index 00000000..de871959 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/playerActivity.json @@ -0,0 +1,23 @@ +{ + "name": "playerActivity", + "displayName": "Player Activity", + "description": "Daily game and puzzle activity for a player, one row per active day", + "tags": ["Users", "Activity"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/user/{{object.rawId}}/activity", + "postRequestScript": "playerActivity.js" + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Player"] } }, + "metadata": [ + { "name": "date", "displayName": "Date", "shape": "date", "role": "timestamp" }, + { "name": "gamesWin", "displayName": "Games won", "shape": "number" }, + { "name": "gamesLoss", "displayName": "Games lost", "shape": "number" }, + { "name": "gamesDraw", "displayName": "Games drawn", "shape": "number" }, + { "name": "puzzlesWin", "displayName": "Puzzles won", "shape": "number" }, + { "name": "puzzlesLoss", "displayName": "Puzzles lost", "shape": "number" }, + { "name": "puzzlesDraw", "displayName": "Puzzles drawn", "shape": "number" } + ], + "timeframes": false +} diff --git a/plugins/Lichess/v1/dataStreams/playerProfile.json b/plugins/Lichess/v1/dataStreams/playerProfile.json new file mode 100644 index 00000000..e5ddc702 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/playerProfile.json @@ -0,0 +1,31 @@ +{ + "name": "playerProfile", + "displayName": "Player Profile", + "description": "Current profile and ratings for a player, one row per player", + "tags": ["Users", "Rating"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/user/{{object.rawId}}", + "postRequestScript": "playerProfile.js" + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Player"] } }, + "metadata": [ + { "name": "id", "displayName": "ID", "shape": "string", "visible": false }, + { "name": "username", "displayName": "Username", "shape": "string", "role": "label" }, + { "name": "title", "displayName": "Title", "shape": "string" }, + { "name": "url", "displayName": "Profile", "shape": ["url", { "label": "View on Lichess" }] }, + { "name": "createdAt", "displayName": "Created", "shape": "date" }, + { "name": "seenAt", "displayName": "Last seen", "shape": "date" }, + { "name": "gamesAll", "displayName": "Games played", "shape": ["number", { "thousandsSeparator": true }] }, + { "name": "gamesWin", "displayName": "Wins", "shape": ["number", { "thousandsSeparator": true }] }, + { "name": "gamesLoss", "displayName": "Losses", "shape": ["number", { "thousandsSeparator": true }] }, + { "name": "gamesDraw", "displayName": "Draws", "shape": ["number", { "thousandsSeparator": true }] }, + { "name": "bulletRating", "displayName": "Bullet rating", "shape": "number" }, + { "name": "blitzRating", "displayName": "Blitz rating", "shape": "number" }, + { "name": "rapidRating", "displayName": "Rapid rating", "shape": "number" }, + { "name": "classicalRating", "displayName": "Classical rating", "shape": "number" }, + { "sourceId": "id", "sourceType": "Lichess Player", "name": "username" } + ], + "timeframes": false +} diff --git a/plugins/Lichess/v1/dataStreams/playerRatingHistory.json b/plugins/Lichess/v1/dataStreams/playerRatingHistory.json new file mode 100644 index 00000000..72e03e0d --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/playerRatingHistory.json @@ -0,0 +1,19 @@ +{ + "name": "playerRatingHistory", + "displayName": "Player Rating History", + "description": "Rating history for a player, one row per variant per rating change", + "tags": ["Rating", "Users"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/user/{{object.rawId}}/rating-history", + "postRequestScript": "playerRatingHistory.js" + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Player"] } }, + "metadata": [ + { "name": "variant", "displayName": "Variant", "shape": "string", "role": "label" }, + { "name": "date", "displayName": "Date", "shape": "date", "role": "timestamp" }, + { "name": "rating", "displayName": "Rating", "shape": "number" } + ], + "timeframes": true +} diff --git a/plugins/Lichess/v1/dataStreams/playerRecentGames.json b/plugins/Lichess/v1/dataStreams/playerRecentGames.json new file mode 100644 index 00000000..d7d3dabe --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/playerRecentGames.json @@ -0,0 +1,34 @@ +{ + "name": "playerRecentGames", + "displayName": "Player Recent Games", + "description": "Games played by a player within the timeframe, one row per game", + "tags": ["Games"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/games/user/{{object.rawId}}", + "headers": [{ "key": "Accept", "value": "application/x-ndjson" }], + "getArgs": [ + { "key": "since", "value": "{{timeframe.unixStart * 1000}}" }, + { "key": "until", "value": "{{timeframe.unixEnd * 1000}}" }, + { "key": "opening", "value": "true" }, + { "key": "max", "value": "200" } + ], + "postRequestScript": "playerRecentGames.js" + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Player"] } }, + "metadata": [ + { "name": "id", "displayName": "Game ID", "shape": "string" }, + { "name": "rated", "displayName": "Rated", "shape": "boolean" }, + { "name": "variant", "displayName": "Variant", "shape": "string" }, + { "name": "speed", "displayName": "Speed", "shape": "string" }, + { "name": "createdAt", "displayName": "Started", "shape": "date", "role": "timestamp" }, + { "name": "whiteUsername", "displayName": "White", "shape": "string", "role": "label" }, + { "name": "whiteRating", "displayName": "White rating", "shape": "number" }, + { "name": "blackUsername", "displayName": "Black", "shape": "string" }, + { "name": "blackRating", "displayName": "Black rating", "shape": "number" }, + { "name": "winner", "displayName": "Winner", "shape": "string" }, + { "name": "openingName", "displayName": "Opening", "shape": "string" } + ], + "timeframes": true +} diff --git a/plugins/Lichess/v1/dataStreams/players.json b/plugins/Lichess/v1/dataStreams/players.json new file mode 100644 index 00000000..5381fab8 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/players.json @@ -0,0 +1,19 @@ +{ + "name": "players", + "displayName": "Players", + "description": "Usernames configured to be tracked as players, one row per username", + "tags": ["Users"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "api/account", + "postRequestScript": "players.js" + }, + "matches": "none", + "visibility": { "type": "hidden" }, + "metadata": [ + { "name": "id", "displayName": "ID", "shape": "string" }, + { "name": "username", "displayName": "Username", "shape": "string", "role": "label" } + ], + "timeframes": false +} diff --git a/plugins/Lichess/v1/dataStreams/scripts/playerActivity.js b/plugins/Lichess/v1/dataStreams/scripts/playerActivity.js new file mode 100644 index 00000000..4d08b2b0 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/scripts/playerActivity.js @@ -0,0 +1,25 @@ +// dataStreams/scripts/playerActivity.js +// +// `games` is keyed by perf/variant (blitz, bullet, rapid, ...) with win/loss/draw +// directly on each entry. Correspondence games that finished that day are NOT +// reported under `games` - they appear under a separate `correspondenceEnds` key, +// keyed by variant, with the win/loss/draw nested one level deeper under `.score` +// (`correspondenceEnds.correspondence.score.win`). `correspondenceMoves` is moves +// made in ongoing correspondence games (no result yet) and is intentionally excluded. +const scoreOf = (perfEntry) => (perfEntry && perfEntry.score) || perfEntry || {}; +const sumField = (map, field) => + Object.values(map || {}).reduce((sum, perfEntry) => sum + (scoreOf(perfEntry)[field] || 0), 0); + +result = (data || []).map((entry) => { + const puzzles = (entry.puzzles && entry.puzzles.score) || {}; + + return { + date: entry.interval && entry.interval.start ? new Date(entry.interval.start).toISOString() : null, + gamesWin: sumField(entry.games, "win") + sumField(entry.correspondenceEnds, "win"), + gamesLoss: sumField(entry.games, "loss") + sumField(entry.correspondenceEnds, "loss"), + gamesDraw: sumField(entry.games, "draw") + sumField(entry.correspondenceEnds, "draw"), + puzzlesWin: puzzles.win || 0, + puzzlesLoss: puzzles.loss || 0, + puzzlesDraw: puzzles.draw || 0 + }; +}); diff --git a/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js b/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js new file mode 100644 index 00000000..8ccde9cf --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js @@ -0,0 +1,23 @@ +// dataStreams/scripts/playerProfile.js +const perfs = data.perfs || {}; +const rating = (key) => (perfs[key] ? perfs[key].rating : null); +const count = data.count || {}; + +result = [ + { + id: data.id, + username: data.username, + title: data.title || "", + url: `https://lichess.org/@/${data.username}`, + createdAt: data.createdAt, + seenAt: data.seenAt, + gamesAll: count.all || 0, + gamesWin: count.win || 0, + gamesLoss: count.loss || 0, + gamesDraw: count.draw || 0, + bulletRating: rating("bullet"), + blitzRating: rating("blitz"), + rapidRating: rating("rapid"), + classicalRating: rating("classical"), + }, +]; diff --git a/plugins/Lichess/v1/dataStreams/scripts/playerRatingHistory.js b/plugins/Lichess/v1/dataStreams/scripts/playerRatingHistory.js new file mode 100644 index 00000000..cec0ba1d --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/scripts/playerRatingHistory.js @@ -0,0 +1,17 @@ +// dataStreams/scripts/playerRatingHistory.js +// The rating-history endpoint takes no query params and always returns the +// player's full history, so the timeframe filter is applied client-side here. +const rows = (data || []).flatMap((v) => + (v.points || []).map((p) => ({ + variant: v.name, + date: new Date(Date.UTC(p[0], p[1], p[2])).toISOString(), + rating: p[3], + })), +); + +const timeframe = context && context.timeframe; + +result = + timeframe && timeframe.start && timeframe.end + ? rows.filter((r) => r.date >= timeframe.start && r.date <= timeframe.end) + : rows; diff --git a/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js b/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js new file mode 100644 index 00000000..767ea114 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js @@ -0,0 +1,32 @@ +// dataStreams/scripts/playerRecentGames.js +// The games/user endpoint returns NDJSON, not JSON, so `data` is undefined - +// parse `response.body` ourselves, one game object per line. +const UNFINISHED_STATUSES = ["created", "started"]; + +result = (response.body || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)) + .map((game) => { + const white = (game.players && game.players.white) || {}; + const black = (game.players && game.players.black) || {}; + + const winner = + game.winner || + (UNFINISHED_STATUSES.includes(game.status) ? null : "draw"); + + return { + id: game.id, + rated: !!game.rated, + variant: game.variant, + speed: game.speed, + createdAt: game.createdAt ? new Date(game.createdAt).toISOString() : null, + whiteUsername: (white.user && white.user.name) || "Anonymous", + whiteRating: typeof white.rating === "number" ? white.rating : null, + blackUsername: (black.user && black.user.name) || "Anonymous", + blackRating: typeof black.rating === "number" ? black.rating : null, + winner: winner, + openingName: (game.opening && game.opening.name) || "", + }; + }); diff --git a/plugins/Lichess/v1/dataStreams/scripts/players.js b/plugins/Lichess/v1/dataStreams/scripts/players.js new file mode 100644 index 00000000..2ed799f7 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/scripts/players.js @@ -0,0 +1,20 @@ +// dataStreams/scripts/players.js +// Calls GET /api/account purely as a cheap, already-authenticated endpoint to +// hang the request on. Lichess only offers a bulk "get users by name" +// endpoint as a raw text/plain POST body, which this plugin framework +// cannot send (a templated postBody is validated as JSON before send, and a +// preRequestScript body override isn't applied either - confirmed by testing). +// So the rows here come entirely from the plugin's configured usernames, not +// from this endpoint's response; per-player profile data is fetched by the +// dependent playerProfile step/stream instead. +const usernames = ( + (context.dataSources[0] && context.dataSources[0].usernames) || "" +) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +result = usernames.map((username) => ({ + id: username.toLowerCase(), + username, +})); diff --git a/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js b/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js new file mode 100644 index 00000000..eb20ad71 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js @@ -0,0 +1,8 @@ +// dataStreams/scripts/teamMembers.js +// Response is NDJSON (one member object per line), not JSON, so `data` is +// undefined - parse `response.body` ourselves. +result = response.body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); diff --git a/plugins/Lichess/v1/dataStreams/teamMembers.json b/plugins/Lichess/v1/dataStreams/teamMembers.json new file mode 100644 index 00000000..92da271e --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/teamMembers.json @@ -0,0 +1,21 @@ +{ + "name": "teamMembers", + "displayName": "Team Members", + "description": "Members of a team, one row per member", + "tags": ["Teams"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/team/{{object.rawId}}/users", + "postRequestScript": "teamMembers.js" + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Team"] } }, + "metadata": [ + { "name": "id", "displayName": "ID", "shape": "string", "visible": false }, + { "name": "name", "displayName": "Name", "shape": "string", "role": "label" }, + { "name": "url", "displayName": "Profile", "shape": ["url", { "label": "View on Lichess" }] }, + { "name": "joinedTeamAt", "displayName": "Joined team", "shape": "date" }, + { "name": "title", "displayName": "Title", "shape": "string" } + ], + "timeframes": false +} diff --git a/plugins/Lichess/v1/dataStreams/teams.json b/plugins/Lichess/v1/dataStreams/teams.json new file mode 100644 index 00000000..12cc6e56 --- /dev/null +++ b/plugins/Lichess/v1/dataStreams/teams.json @@ -0,0 +1,21 @@ +{ + "name": "teams", + "displayName": "Teams", + "description": "Teams that tracked players belong to, one row per team", + "tags": ["Teams"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "api/team/of/{{object.rawId}}" + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Player"] } }, + "visibility": { "type": "hidden" }, + "metadata": [ + { "name": "id", "displayName": "ID", "shape": "string" }, + { "name": "name", "displayName": "Name", "shape": "string", "role": "label" }, + { "name": "description", "displayName": "Description", "shape": "string" }, + { "name": "open", "displayName": "Open", "shape": "boolean" }, + { "name": "nbMembers", "displayName": "Members", "shape": "number" } + ], + "timeframes": false +} diff --git a/plugins/Lichess/v1/defaultContent/manifest.json b/plugins/Lichess/v1/defaultContent/manifest.json new file mode 100644 index 00000000..ab9db854 --- /dev/null +++ b/plugins/Lichess/v1/defaultContent/manifest.json @@ -0,0 +1,7 @@ +{ + "items": [ + { "name": "overview", "type": "dashboard" }, + { "name": "player", "type": "dashboard" }, + { "name": "team", "type": "dashboard" } + ] +} diff --git a/plugins/Lichess/v1/defaultContent/overview.dash.json b/plugins/Lichess/v1/defaultContent/overview.dash.json new file mode 100644 index 00000000..d6306046 --- /dev/null +++ b/plugins/Lichess/v1/defaultContent/overview.dash.json @@ -0,0 +1,176 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "version": 1, + "columns": 4, + "contents": [ + { + "i": "e705f6b3-1c9e-41c9-aa0b-5acc1ee0c20c", + "x": 0, + "y": 0, + "w": 2, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Tracked Players", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[playerProfile]}}", + "name": "playerProfile", + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Tracked Players" + } + } + } + } + }, + { + "i": "33d79fce-495b-4893-b218-a858025beabb", + "x": 2, + "y": 0, + "w": 2, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Tracked Teams", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "datastream-properties", + "name": "properties", + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "scope": { + "scope": "{{scopes.[Lichess Teams]}}", + "workspace": "{{workspaceId}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Tracked Teams" + } + } + } + } + }, + { + "i": "df5d7a68-4bf3-4709-94e9-68f03a308052", + "x": 0, + "y": 2, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Players", + "description": "Current profile and ratings for every tracked player", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[playerProfile]}}", + "name": "playerProfile", + "sort": { "by": [["username", "asc"]] } + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}" + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "username", + "title", + "bulletRating", + "blitzRating", + "rapidRating", + "classicalRating", + "gamesAll", + "gamesWin", + "gamesLoss", + "gamesDraw", + "seenAt", + "url" + ], + "hiddenColumns": ["id", "createdAt"] + } + } + } + } + }, + { + "i": "1952bfb1-4196-4ead-8775-bbd3f0ea0247", + "x": 0, + "y": 6, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Teams", + "description": "Tracked teams and their membership", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "datastream-properties", + "name": "properties", + "sort": { "by": [["nbMembers", "desc"]] } + }, + "scope": { + "scope": "{{scopes.[Lichess Teams]}}", + "workspace": "{{workspaceId}}" + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["name", "description", "open", "nbMembers"], + "hiddenColumns": ["id", "links", "type", "label", "sourceId", "sourceType"] + } + } + } + } + } + ] + } +} diff --git a/plugins/Lichess/v1/defaultContent/player.dash.json b/plugins/Lichess/v1/defaultContent/player.dash.json new file mode 100644 index 00000000..96bfbac9 --- /dev/null +++ b/plugins/Lichess/v1/defaultContent/player.dash.json @@ -0,0 +1,294 @@ +{ + "name": "Player", + "schemaVersion": "1.5", + "timeframe": "last30days", + "variables": ["{{variables.[Lichess Player]}}"], + "dashboard": { + "_type": "layout/grid", + "version": 1, + "columns": 4, + "contents": [ + { + "i": "b66255a1-e75d-4aa4-97c6-6c3454fa4eac", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Bullet Rating", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerProfile]}}", + "name": "playerProfile" + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "bulletRating", + "comparisonColumn": "none", + "label": "Bullet" + } + } + } + } + }, + { + "i": "ef5624bc-c7ca-433c-9b4f-bc8a54bf88ff", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Blitz Rating", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerProfile]}}", + "name": "playerProfile" + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "blitzRating", + "comparisonColumn": "none", + "label": "Blitz" + } + } + } + } + }, + { + "i": "8d251ba5-45a3-4593-8508-3db23bd6a206", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Rapid Rating", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerProfile]}}", + "name": "playerProfile" + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "rapidRating", + "comparisonColumn": "none", + "label": "Rapid" + } + } + } + } + }, + { + "i": "c2395ccb-f028-4948-ad07-2a90d374d13d", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Classical Rating", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerProfile]}}", + "name": "playerProfile" + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "classicalRating", + "comparisonColumn": "none", + "label": "Classical" + } + } + } + } + }, + { + "i": "f807d193-9f8d-4ac9-b65f-e47e7b921f1a", + "x": 0, + "y": 2, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Rating History", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerRatingHistory]}}", + "name": "playerRatingHistory", + "sort": { "by": [["date", "asc"]] } + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "xAxisColumn": "date", + "yAxisColumn": ["rating"], + "seriesColumn": "variant", + "showLegend": true, + "legendPosition": "bottom", + "yAxisLabel": "Rating", + "showYAxisLabel": true, + "showTrendLine": false + } + } + } + } + }, + { + "i": "bbe73957-fd1c-48c0-a014-0a72ab29dd2b", + "x": 2, + "y": 2, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Activity", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerActivity]}}", + "name": "playerActivity", + "sort": { "by": [["date", "asc"]] } + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "xAxisColumn": "date", + "yAxisColumn": ["gamesWin", "gamesLoss", "gamesDraw"], + "seriesColumn": "none", + "showLegend": true, + "legendPosition": "bottom", + "yAxisLabel": "Games", + "showYAxisLabel": true, + "showTrendLine": false + } + } + } + } + }, + { + "i": "f94516af-9255-4d87-8547-7aadffa44e89", + "x": 0, + "y": 6, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Games", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Player]}}"], + "dataStream": { + "id": "{{dataStreams.[playerRecentGames]}}", + "name": "playerRecentGames", + "sort": { "by": [["createdAt", "desc"]] } + }, + "scope": { + "scope": "{{scopes.[Lichess Players]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Player]}}" + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "createdAt", + "whiteUsername", + "whiteRating", + "blackUsername", + "blackRating", + "winner", + "variant", + "speed", + "rated", + "openingName" + ], + "hiddenColumns": ["id"] + } + } + } + } + } + ] + } +} diff --git a/plugins/Lichess/v1/defaultContent/scopes.json b/plugins/Lichess/v1/defaultContent/scopes.json new file mode 100644 index 00000000..cd465103 --- /dev/null +++ b/plugins/Lichess/v1/defaultContent/scopes.json @@ -0,0 +1,26 @@ +[ + { + "name": "Lichess Players", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Lichess Player"] } + }, + "variable": { + "name": "Lichess Player", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Lichess Teams", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Lichess Team"] } + }, + "variable": { + "name": "Lichess Team", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/Lichess/v1/defaultContent/team.dash.json b/plugins/Lichess/v1/defaultContent/team.dash.json new file mode 100644 index 00000000..e90eadb5 --- /dev/null +++ b/plugins/Lichess/v1/defaultContent/team.dash.json @@ -0,0 +1,88 @@ +{ + "name": "Team", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": ["{{variables.[Lichess Team]}}"], + "dashboard": { + "_type": "layout/grid", + "version": 1, + "columns": 4, + "contents": [ + { + "i": "f6fddb78-bf0e-489b-8bcd-50bbc0b57a8c", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Details", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Team]}}"], + "dataStream": { + "id": "datastream-properties", + "name": "properties" + }, + "scope": { + "scope": "{{scopes.[Lichess Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Team]}}" + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true, + "columnOrder": ["name", "description", "open", "nbMembers"], + "hiddenColumns": ["id", "links", "type", "label", "sourceId", "sourceType"] + } + } + } + } + }, + { + "i": "b79144b0-4673-4152-b815-708e9b594169", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Members", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "variables": ["{{variables.[Lichess Team]}}"], + "dataStream": { + "id": "{{dataStreams.[teamMembers]}}", + "name": "teamMembers", + "sort": { "by": [["joinedTeamAt", "desc"]] } + }, + "scope": { + "scope": "{{scopes.[Lichess Teams]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Lichess Team]}}" + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["name", "title", "joinedTeamAt", "url"], + "hiddenColumns": ["id"] + } + } + } + } + } + ] + } +} diff --git a/plugins/Lichess/v1/docs/README.md b/plugins/Lichess/v1/docs/README.md new file mode 100644 index 00000000..df977231 --- /dev/null +++ b/plugins/Lichess/v1/docs/README.md @@ -0,0 +1,56 @@ +Track [Lichess](https://lichess.org) players and teams in SquaredUp — ratings, rating history, recent games, and team membership — via the [Lichess API](https://lichess.org/api). + +## Setup + +You will need a Lichess **personal API access token**. + +1. Sign in to [lichess.org](https://lichess.org). +2. Go to [**Preferences → API access tokens**](https://lichess.org/account/oauth/token) and click **New personal access token**. +3. Give it a description; no scopes need to be selected, since this plugin only reads public data. +4. Click **Create** and copy the token — Lichess only shows it once. +5. Paste it into the **API token** field, along with the usernames you want to track. + +## Configuration fields + +| Field | What it is | Where to find it | Required | +| ----- | ---------- | ----------------- | -------- | +| **API token** | Authenticates every request via the `Authorization: Bearer` header. | Lichess → [**Preferences → API access tokens**](https://lichess.org/account/oauth/token). | Yes | +| **Usernames** | Comma-separated list of Lichess usernames to track as **Lichess Player** objects. Any team a tracked player belongs to is imported as a **Lichess Team** object automatically — Lichess has no API to look up an arbitrary team by ID directly. | The username as it appears in the player's profile URL, e.g. `lichess.org/@/DrNykterstein` → `DrNykterstein`. | Yes | + +On save, the plugin validates the token by fetching your Lichess account profile; an invalid or expired token fails setup with an authentication error. + +## What this plugin monitors + +- **Player profiles and ratings** — title, account age, last-seen time, game counts, and current Bullet/Blitz/Rapid/Classical ratings. +- **Rating history** — how each rating has moved over time, per variant. +- **Recent activity and games** — daily win/loss/draw activity (including puzzles) and a log of recent games with opponents, result, and opening. +- **Team membership** — teams a tracked player belongs to, and each team's current member list. + +The out-of-the-box dashboards include an estate-wide **Overview** plus a perspective for each **Player** and **Team**. + +## Data streams + +- **Current User** — the authenticated account's own profile, used only to validate the API token on setup. +- **Players** — the configured usernames to track, one row per username. +- **Player Profile** — current profile and ratings for a player, one row per player. +- **Teams** — teams a player belongs to, one row per team; used only to discover which teams to track. +- **Player Rating History** — rating over time per chess variant for a player. +- **Player Activity** — daily game and puzzle win/loss/draw counts for a player. +- **Player Recent Games** — recent games for a player, with opponent, result, and opening. +- **Team Members** — current member list for a team. + +## What gets indexed + +| Object type | API source | Represents | +| ----------- | ---------- | ---------- | +| **Lichess Player** | Configured usernames, enriched via `GET /api/user/{username}` | A tracked chess player. | +| **Lichess Team** | `GET /api/team/of/{username}` for each tracked player | A team a tracked player belongs to. | + +## Known limitations + +- **Teams are discovered through tracked players, not by ID** — Lichess has no API to fetch an arbitrary team by ID in bulk, so only teams that a tracked player actually belongs to are imported; there's no way to track a team with no tracked members. +- **Large teams' member lists can time out** — `GET /api/team/{id}/users` is throttled by Lichess at roughly 50 members/second regardless of caller, and the platform allows a data stream up to 25 seconds to complete. In practice, a team with more than roughly 1,200 members will time out on the **Members** tile; smaller teams are unaffected. +- **Rating history has no server-side time filtering** — the underlying endpoint always returns a player's complete history; the dashboard timeframe picker narrows it down after the fact, so very short windows (e.g. last hour) will typically show no data even for active players. +- **Recent games are capped at 200 per request** to stay within response size limits — very active players' full game history isn't available in one tile. +- **Activity history is a fixed rolling window** — the Player Activity stream reflects roughly the last 20 days with recorded activity and has no timeframe picker. +- **Read-only** — the plugin never creates, modifies, or deletes anything in Lichess. diff --git a/plugins/Lichess/v1/icon.svg b/plugins/Lichess/v1/icon.svg new file mode 100644 index 00000000..1070b11f --- /dev/null +++ b/plugins/Lichess/v1/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/Lichess/v1/indexDefinitions/default.json b/plugins/Lichess/v1/indexDefinitions/default.json new file mode 100644 index 00000000..6e0fb3dd --- /dev/null +++ b/plugins/Lichess/v1/indexDefinitions/default.json @@ -0,0 +1,46 @@ +{ + "steps": [ + { + "name": "players", + "dataStream": { "name": "players" }, + "timeframe": "none", + "objectMapping": { + "id": "id", + "name": "username", + "type": { "value": "Lichess Player" } + } + }, + { + "name": "playerProfiles", + "dataStream": { "name": "playerProfile" }, + "scope": { + "query": "g.V().has(\"sourceType\", \"Lichess Player\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "id", + "name": "username", + "type": { "value": "Lichess Player" }, + "properties": ["title", "createdAt"] + }, + "optional": true, + "dependsOn": ["players"] + }, + { + "name": "teams", + "dataStream": { "name": "teams" }, + "scope": { + "query": "g.V().has(\"sourceType\", \"Lichess Player\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "id", + "name": "name", + "type": { "value": "Lichess Team" }, + "properties": ["description", "open", "nbMembers"] + }, + "optional": true, + "dependsOn": ["players"] + } + ] +} diff --git a/plugins/Lichess/v1/metadata.json b/plugins/Lichess/v1/metadata.json new file mode 100644 index 00000000..f0b23ed5 --- /dev/null +++ b/plugins/Lichess/v1/metadata.json @@ -0,0 +1,41 @@ +{ + "name": "lichess", + "displayName": "Lichess", + "version": "1.0.0", + "author": { + "name": "@morgan-evans", + "type": "community" + }, + "description": "Track Lichess players and teams - ratings, rating history, recent games, and team membership - via the Lichess API.", + "category": "Fun", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": ["lichess", "chess", "games", "rating", "elo"], + "objectTypes": ["Lichess Player", "Lichess Team"], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/Lichess/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/Lichess/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "https://lichess.org", + "authMode": "none", + "headers": [ + { "key": "Authorization", "value": "Bearer {{apiToken}}" } + ], + "queryArgs": [] + } + } +} diff --git a/plugins/Lichess/v1/ui.json b/plugins/Lichess/v1/ui.json new file mode 100644 index 00000000..20295c7c --- /dev/null +++ b/plugins/Lichess/v1/ui.json @@ -0,0 +1,18 @@ +[ + { + "type": "password", + "name": "apiToken", + "label": "API token", + "help": "A Lichess personal access token from [Preferences → API access tokens](https://lichess.org/account/oauth/token). No scopes are required.", + "validation": { "required": true } + }, + { + "type": "textarea", + "name": "usernames", + "label": "Usernames", + "rows": 3, + "placeholder": "DrNykterstein, penguingim1", + "help": "Comma-separated Lichess usernames to track as **Lichess Player** objects, e.g. from `lichess.org/@/DrNykterstein` use `DrNykterstein`. Any Lichess team these players belong to is also tracked as a **Lichess Team** object.", + "validation": { "required": true } + } +] From 5bc97406584393fd0a946eaac9f37f6c7fc96827 Mon Sep 17 00:00:00 2001 From: Morgan Evans Date: Fri, 18 Sep 2026 16:40:07 +0100 Subject: [PATCH 2/3] Single data item bug fix --- .../Lichess/v1/dataStreams/currentUser.json | 2 +- .../v1/dataStreams/playerActivity.json | 2 +- .../v1/dataStreams/playerRecentGames.json | 1 + .../dataStreams/scripts/playerRecentGames.js | 67 +++++++++++-------- .../v1/dataStreams/scripts/teamMembers.js | 25 +++++-- .../Lichess/v1/dataStreams/teamMembers.json | 1 + plugins/Lichess/v1/docs/README.md | 4 +- 7 files changed, 65 insertions(+), 37 deletions(-) diff --git a/plugins/Lichess/v1/dataStreams/currentUser.json b/plugins/Lichess/v1/dataStreams/currentUser.json index 3a94204e..11950fad 100644 --- a/plugins/Lichess/v1/dataStreams/currentUser.json +++ b/plugins/Lichess/v1/dataStreams/currentUser.json @@ -2,7 +2,7 @@ "name": "currentUser", "displayName": "Current User", "description": "The authenticated Lichess account's own profile", - "tags": ["Account"], + "tags": ["Users"], "baseDataSourceName": "httpRequestUnscoped", "config": { "httpMethod": "get", diff --git a/plugins/Lichess/v1/dataStreams/playerActivity.json b/plugins/Lichess/v1/dataStreams/playerActivity.json index de871959..6fa66570 100644 --- a/plugins/Lichess/v1/dataStreams/playerActivity.json +++ b/plugins/Lichess/v1/dataStreams/playerActivity.json @@ -1,6 +1,6 @@ { "name": "playerActivity", - "displayName": "Player Activity", + "displayName": "Recent Player Activity", "description": "Daily game and puzzle activity for a player, one row per active day", "tags": ["Users", "Activity"], "baseDataSourceName": "httpRequestScopedSingle", diff --git a/plugins/Lichess/v1/dataStreams/playerRecentGames.json b/plugins/Lichess/v1/dataStreams/playerRecentGames.json index d7d3dabe..813376cd 100644 --- a/plugins/Lichess/v1/dataStreams/playerRecentGames.json +++ b/plugins/Lichess/v1/dataStreams/playerRecentGames.json @@ -7,6 +7,7 @@ "config": { "httpMethod": "get", "endpointPath": "api/games/user/{{object.rawId}}", + "errorHandling": { "type": "path", "realm": "payload", "path": "error" }, "headers": [{ "key": "Accept", "value": "application/x-ndjson" }], "getArgs": [ { "key": "since", "value": "{{timeframe.unixStart * 1000}}" }, diff --git a/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js b/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js index 767ea114..2291da4a 100644 --- a/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js +++ b/plugins/Lichess/v1/dataStreams/scripts/playerRecentGames.js @@ -1,32 +1,45 @@ // dataStreams/scripts/playerRecentGames.js -// The games/user endpoint returns NDJSON, not JSON, so `data` is undefined - -// parse `response.body` ourselves, one game object per line. +// The games/user endpoint returns NDJSON, one game object per line. `data` is +// undefined since NDJSON isn't valid JSON, so we parse `response.body` ourselves. +// Edge case: if exactly one game matches (common with narrow timeframes), that +// single line IS valid JSON, and the platform auto-parses it into an object +// instead of leaving it as text - so response.body can be a string, an +// already-parsed single game object, or (defensively) an array of them. const UNFINISHED_STATUSES = ["created", "started"]; -result = (response.body || "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line)) - .map((game) => { - const white = (game.players && game.players.white) || {}; - const black = (game.players && game.players.black) || {}; +const body = response.body; +const games = + typeof body === "string" + ? body + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)) + : Array.isArray(body) + ? body + : body + ? [body] + : []; - const winner = - game.winner || - (UNFINISHED_STATUSES.includes(game.status) ? null : "draw"); +result = games.map((game) => { + const white = (game.players && game.players.white) || {}; + const black = (game.players && game.players.black) || {}; - return { - id: game.id, - rated: !!game.rated, - variant: game.variant, - speed: game.speed, - createdAt: game.createdAt ? new Date(game.createdAt).toISOString() : null, - whiteUsername: (white.user && white.user.name) || "Anonymous", - whiteRating: typeof white.rating === "number" ? white.rating : null, - blackUsername: (black.user && black.user.name) || "Anonymous", - blackRating: typeof black.rating === "number" ? black.rating : null, - winner: winner, - openingName: (game.opening && game.opening.name) || "", - }; - }); + const winner = + game.winner || + (UNFINISHED_STATUSES.includes(game.status) ? null : "draw"); + + return { + id: game.id, + rated: !!game.rated, + variant: game.variant, + speed: game.speed, + createdAt: game.createdAt ? new Date(game.createdAt).toISOString() : null, + whiteUsername: (white.user && white.user.name) || "Anonymous", + whiteRating: typeof white.rating === "number" ? white.rating : null, + blackUsername: (black.user && black.user.name) || "Anonymous", + blackRating: typeof black.rating === "number" ? black.rating : null, + winner: winner, + openingName: (game.opening && game.opening.name) || "", + }; +}); diff --git a/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js b/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js index eb20ad71..dfdfc169 100644 --- a/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js +++ b/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js @@ -1,8 +1,21 @@ // dataStreams/scripts/teamMembers.js // Response is NDJSON (one member object per line), not JSON, so `data` is -// undefined - parse `response.body` ourselves. -result = response.body - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line)); +// undefined - parse `response.body` ourselves. Edge case: if the team has +// exactly one member, that single line IS valid JSON, and the platform +// auto-parses it into an object instead of leaving it as text - so +// response.body can be a string, an already-parsed single member object, or +// (defensively) an array of them. +const body = response.body; + +result = + typeof body === "string" + ? body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + : Array.isArray(body) + ? body + : body + ? [body] + : []; diff --git a/plugins/Lichess/v1/dataStreams/teamMembers.json b/plugins/Lichess/v1/dataStreams/teamMembers.json index 92da271e..326b4b0f 100644 --- a/plugins/Lichess/v1/dataStreams/teamMembers.json +++ b/plugins/Lichess/v1/dataStreams/teamMembers.json @@ -7,6 +7,7 @@ "config": { "httpMethod": "get", "endpointPath": "api/team/{{object.rawId}}/users", + "errorHandling": { "type": "path", "realm": "payload", "path": "error" }, "postRequestScript": "teamMembers.js" }, "matches": { "sourceType": { "type": "oneOf", "values": ["Lichess Team"] } }, diff --git a/plugins/Lichess/v1/docs/README.md b/plugins/Lichess/v1/docs/README.md index df977231..5cf24bb6 100644 --- a/plugins/Lichess/v1/docs/README.md +++ b/plugins/Lichess/v1/docs/README.md @@ -35,7 +35,7 @@ The out-of-the-box dashboards include an estate-wide **Overview** plus a perspec - **Player Profile** — current profile and ratings for a player, one row per player. - **Teams** — teams a player belongs to, one row per team; used only to discover which teams to track. - **Player Rating History** — rating over time per chess variant for a player. -- **Player Activity** — daily game and puzzle win/loss/draw counts for a player. +- **Recent Player Activity** — daily game and puzzle win/loss/draw counts for a player. - **Player Recent Games** — recent games for a player, with opponent, result, and opening. - **Team Members** — current member list for a team. @@ -52,5 +52,5 @@ The out-of-the-box dashboards include an estate-wide **Overview** plus a perspec - **Large teams' member lists can time out** — `GET /api/team/{id}/users` is throttled by Lichess at roughly 50 members/second regardless of caller, and the platform allows a data stream up to 25 seconds to complete. In practice, a team with more than roughly 1,200 members will time out on the **Members** tile; smaller teams are unaffected. - **Rating history has no server-side time filtering** — the underlying endpoint always returns a player's complete history; the dashboard timeframe picker narrows it down after the fact, so very short windows (e.g. last hour) will typically show no data even for active players. - **Recent games are capped at 200 per request** to stay within response size limits — very active players' full game history isn't available in one tile. -- **Activity history is a fixed rolling window** — the Player Activity stream reflects roughly the last 20 days with recorded activity and has no timeframe picker. +- **Activity history is a fixed rolling window** — the Recent Player Activity stream reflects roughly the last 20 days with recorded activity and has no timeframe picker. - **Read-only** — the plugin never creates, modifies, or deletes anything in Lichess. From aaceba63d403d47ef55ccbe743da8b6613057a4a Mon Sep 17 00:00:00 2001 From: Morgan Evans Date: Mon, 21 Sep 2026 16:15:20 +0100 Subject: [PATCH 3/3] Implementing code rabbit fixes --- plugins/Lichess/v1/dataStreams/scripts/playerProfile.js | 4 ++-- plugins/Lichess/v1/dataStreams/scripts/players.js | 4 ++++ plugins/Lichess/v1/dataStreams/scripts/teamMembers.js | 7 ++++++- plugins/Lichess/v1/docs/README.md | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js b/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js index 8ccde9cf..be8416ba 100644 --- a/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js +++ b/plugins/Lichess/v1/dataStreams/scripts/playerProfile.js @@ -9,8 +9,8 @@ result = [ username: data.username, title: data.title || "", url: `https://lichess.org/@/${data.username}`, - createdAt: data.createdAt, - seenAt: data.seenAt, + createdAt: data.createdAt != null ? new Date(data.createdAt).toISOString() : null, + seenAt: data.seenAt != null ? new Date(data.seenAt).toISOString() : null, gamesAll: count.all || 0, gamesWin: count.win || 0, gamesLoss: count.loss || 0, diff --git a/plugins/Lichess/v1/dataStreams/scripts/players.js b/plugins/Lichess/v1/dataStreams/scripts/players.js index 2ed799f7..d382d7c2 100644 --- a/plugins/Lichess/v1/dataStreams/scripts/players.js +++ b/plugins/Lichess/v1/dataStreams/scripts/players.js @@ -14,6 +14,10 @@ const usernames = ( .map((s) => s.trim()) .filter(Boolean); +if (usernames.length === 0) { + throw new Error("Usernames is required. Please configure at least one Lichess username."); +} + result = usernames.map((username) => ({ id: username.toLowerCase(), username, diff --git a/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js b/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js index dfdfc169..006c73b6 100644 --- a/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js +++ b/plugins/Lichess/v1/dataStreams/scripts/teamMembers.js @@ -7,7 +7,7 @@ // (defensively) an array of them. const body = response.body; -result = +const members = typeof body === "string" ? body .split("\n") @@ -19,3 +19,8 @@ result = : body ? [body] : []; + +result = members.map((member) => ({ + ...member, + url: `https://lichess.org/@/${encodeURIComponent(member.id)}`, +})); diff --git a/plugins/Lichess/v1/docs/README.md b/plugins/Lichess/v1/docs/README.md index 5cf24bb6..7eb09db7 100644 --- a/plugins/Lichess/v1/docs/README.md +++ b/plugins/Lichess/v1/docs/README.md @@ -49,7 +49,7 @@ The out-of-the-box dashboards include an estate-wide **Overview** plus a perspec ## Known limitations - **Teams are discovered through tracked players, not by ID** — Lichess has no API to fetch an arbitrary team by ID in bulk, so only teams that a tracked player actually belongs to are imported; there's no way to track a team with no tracked members. -- **Large teams' member lists can time out** — `GET /api/team/{id}/users` is throttled by Lichess at roughly 50 members/second regardless of caller, and the platform allows a data stream up to 25 seconds to complete. In practice, a team with more than roughly 1,200 members will time out on the **Members** tile; smaller teams are unaffected. +- **Large teams' member lists can time out** — the platform allows a data stream up to 25 seconds to complete, and `GET /api/team/{id}/users` for a very large team's full member list can exceed that on the **Members** tile. - **Rating history has no server-side time filtering** — the underlying endpoint always returns a player's complete history; the dashboard timeframe picker narrows it down after the fact, so very short windows (e.g. last hour) will typically show no data even for active players. - **Recent games are capped at 200 per request** to stay within response size limits — very active players' full game history isn't available in one tile. - **Activity history is a fixed rolling window** — the Recent Player Activity stream reflects roughly the last 20 days with recorded activity and has no timeframe picker.