🔄 refactor(steam): Migrate to Steam OpenID authentication and official Web API (#282)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added support for managing multiple Steam profiles per user, including
a new profiles page with avatar selection and profile management.
- Introduced a streamlined Steam authentication flow using a popup
window, replacing the previous QR code and team-based login.
- Added utilities for Steam image handling and metadata, including
avatar preloading and static Steam metadata mappings.
  - Enhanced OpenID verification for Steam login.
- Added new image-related events and expanded event handling for Steam
account updates and image processing.

- **Improvements**
- Refactored the account structure from teams to profiles, updating
related UI, context, and storage.
- Updated API headers and authentication logic to use Steam IDs instead
of team IDs.
- Expanded game metadata with new fields for categories, franchises, and
social links.
- Improved library and category schemas for richer game and profile
data.
- Simplified and improved Steam API client methods for fetching user
info, friends, and game libraries using Steam Web API.
- Updated queue processing to handle individual game updates and publish
image events.
- Adjusted permissions and queue configurations for better message
handling and dead-letter queue support.
  - Improved slug creation and rating estimation utilities.

- **Bug Fixes**
- Fixed avatar image loading to display higher quality images after
initial load.

- **Removals**
- Removed all team, member, and credential management functionality and
related database schemas.
  - Eliminated the QR code-based login and related UI components.
  - Deleted legacy team and member database tables and related code.
- Removed encryption utilities and deprecated secret keys in favor of
new secret management.

- **Chores**
- Updated dependencies and internal configuration for new features and
schema changes.
- Cleaned up unused code and updated database migrations for new data
structures.
- Adjusted import orders and removed unused imports across multiple
modules.
- Added new resource declarations and updated service link
configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-06-02 09:22:18 +03:00
committed by GitHub
parent ae364f69bd
commit c0194ecef4
71 changed files with 8268 additions and 2134 deletions

View File

@@ -14,6 +14,9 @@
"peerDependencies": {
"typescript": "^5"
},
"exports": {
"./*": "./src/*.ts"
},
"dependencies": {
"@actor-core/bun": "^0.8.0",
"@actor-core/file-system": "^0.8.0",
@@ -27,4 +30,4 @@
"steamcommunity": "^3.48.6",
"steamid": "^2.1.0"
}
}
}

View File

@@ -20,7 +20,7 @@ export namespace AccountApi {
schema: Result(
Account.Info.openapi({
description: "User account information",
example: { ...Examples.User, teams: [Examples.Team] }
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
})
),
},

View File

@@ -79,9 +79,9 @@ app.get(
},
TeamID: {
type: "apiKey",
description: "The team ID to use for this query",
description: "The steam ID to use for this query",
in: "header",
name: "x-nestri-team"
name: "x-nestri-steam"
},
},
},

View File

@@ -1,22 +1,19 @@
import { z } from "zod";
import { Hono } from "hono";
import crypto from 'crypto';
import { Resource } from "sst";
import { streamSSE } from "hono/streaming";
import { Actor } from "@nestri/core/actor";
import SteamCommunity from "steamcommunity";
import { describeRoute } from "hono-openapi";
import { Team } from "@nestri/core/team/index";
import { User } from "@nestri/core/user/index";
import { Examples } from "@nestri/core/examples";
import { Steam } from "@nestri/core/steam/index";
import { Member } from "@nestri/core/member/index";
import { getCookie, setCookie } from "hono/cookie";
import { Client } from "@nestri/core/client/index";
import { Friend } from "@nestri/core/friend/index";
import { Library } from "@nestri/core/library/index";
import { chunkArray } from "@nestri/core/utils/helper";
import { ErrorResponses, validator, Result } from "./utils";
import { Credentials } from "@nestri/core/credentials/index";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
import { ErrorResponses, validator, Result, notPublic } from "./utils";
const sqs = new SQSClient({});
@@ -45,273 +42,217 @@ export namespace SteamApi {
429: ErrorResponses[429],
}
}),
notPublic,
async (c) =>
c.json({
data: await Steam.list()
})
)
.get("/login",
.get("/callback/:id",
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the user to login",
example: Examples.User.id,
}),
}),
),
async (c) => {
const cookieID = getCookie(c, "user_id");
const userID = c.req.valid("param").id;
if (!cookieID || cookieID !== userID) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"You should not be here"
);
}
const currentUser = await User.fromID(userID);
if (!currentUser) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} not found`
)
}
const params = new URL(c.req.url).searchParams;
// Verify OpenID response and get steamID
const steamID = await Client.verifyOpenIDResponse(params);
// If verification failed, return error
if (!steamID) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Invalid OpenID authentication response"
);
}
const user = (await Client.getUserInfo([steamID]))[0];
if (!user) {
throw new VisibleError(
"internal",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
"Steam user data is missing"
);
}
const wasAdded = await Steam.create({ ...user, userID });
if (!wasAdded) {
// Update the owner of the Steam account
await Steam.updateOwner({ userID, steamID })
}
c.executionCtx.waitUntil((async () => {
try {
// Get friends info
const friends = await Client.getFriendsList(steamID);
const friendSteamIDs = friends.friendslist.friends.map(f => f.steamid);
// Steam API has a limit of requesting 100 friends at a go
const friendChunks = chunkArray(friendSteamIDs, 100);
const settled = await Promise.allSettled(
friendChunks.map(async (friendIDs) => {
const friendsInfo = await Client.getUserInfo(friendIDs)
return await Promise.all(
friendsInfo.map(async (friend) => {
const wasAdded = await Steam.create(friend);
if (!wasAdded) {
console.log(`Friend ${friend.id} already exists`)
}
await Friend.add({ friendSteamID: friend.id, steamID })
return friend.id
})
)
})
)
settled
.filter(result => result.status === 'rejected')
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
const prod = (Resource.App.stage === "production" || Resource.App.stage === "dev")
const friendIDs = [
steamID,
...(prod ? settled
.filter(result => result.status === "fulfilled")
.map(f => f.value)
.flat() : [])
]
await Promise.all(
friendIDs.map(async (currentSteamID) => {
// Get user library
const gameLibrary = await Client.getUserLibrary(currentSteamID);
const queryLib = await Promise.allSettled(
gameLibrary.response.games.map(async (game) => {
await Actor.provide(
"steam",
{
steamID: currentSteamID,
},
async () => {
const payload = await Library.Events.Queue.create({
appID: game.appid,
lastPlayed: game.rtime_last_played ? new Date(game.rtime_last_played * 1000) : null,
totalPlaytime: game.playtime_forever
});
await sqs.send(
new SendMessageCommand({
QueueUrl: Resource.LibraryQueue.url,
// Prevent bombarding Steam with requests at the same time
DelaySeconds: 10,
MessageBody: JSON.stringify(payload),
})
)
}
)
})
)
queryLib
.filter(i => i.status === "rejected")
.forEach(e => console.warn(`[pushUserLib]: Failed to push user library to queue: ${e.reason}`))
})
)
} catch (error: any) {
console.error(`Failed to process Steam data for user ${userID}:`, error);
}
})())
return c.html(
`
<script>
window.location.href = "about:blank";
window.close()
</script>
`
)
}
)
.get("/popup/:id",
describeRoute({
tags: ["Steam"],
summary: "Login to Steam using QR code",
description: "Login to Steam using a QR code sent using Server Sent Events",
summary: "Login to Steam",
description: "Login to Steam in a popup",
responses: {
400: ErrorResponses[400],
429: ErrorResponses[429],
}
}),
validator(
"header",
"param",
z.object({
"accept": z.string()
.refine((v) =>
v.toLowerCase()
.includes("text/event-stream")
)
.openapi({
description: "Client must accept Server Sent Events",
example: "text/event-stream"
})
})
id: z.string().openapi({
description: "ID of the user to login",
example: Examples.User.id,
}),
}),
),
(c) => {
const currentUser = Actor.user()
async (c) => {
const userID = c.req.valid("param").id;
return streamSSE(c, async (stream) => {
const user = await User.fromID(userID);
if (!user) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} not found`
)
}
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
setCookie(c, "user_id", user.id);
session.loginTimeout = 30000; //30 seconds is typically when the url expires
const returnUrl = `${new URL(c.req.url).origin}/steam/callback/${userID}`
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "connected to steam" })
})
const params = new URLSearchParams({
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.mode': 'checkid_setup',
'openid.return_to': returnUrl,
'openid.realm': new URL(returnUrl).origin,
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
'user_id': user.id
});
const challenge = await session.startWithQR();
await stream.writeSSE({
event: 'challenge_url',
data: JSON.stringify({ url: challenge.qrChallengeUrl })
})
return new Promise((resolve, reject) => {
session.on('remoteInteraction', async () => {
await stream.writeSSE({
event: 'remote_interaction',
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
})
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
})
});
session.on('timeout', async () => {
console.log('This login attempt has timed out.');
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Your session timed out" }),
})
await stream.writeSSE({
event: 'timed_out',
data: JSON.stringify({ success: false }),
})
await stream.close()
reject("Authentication timed out")
});
session.on('error', async (err) => {
// This should ordinarily not happen. This only happens in case there's some kind of unexpected error while
// polling, e.g. the network connection goes down or Steam chokes on something.
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Recieved an error while authenticating" }),
})
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message: err.message }),
})
await stream.close()
reject(err.message)
});
session.on('authenticated', async () => {
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Login successful" })
})
await stream.writeSSE({
event: 'login_success',
data: JSON.stringify({ success: true, })
})
const username = session.accountName;
const accessToken = session.accessToken;
const refreshToken = session.refreshToken;
const steamID = session.steamID.toString();
const cookies = await session.getWebCookies();
// Get user information
const community = new SteamCommunity();
community.setCookies(cookies);
const user = await Client.getUserInfo({ steamID, cookies })
const wasAdded =
await Steam.create({
username,
id: steamID,
name: user.name,
realName: user.realName,
userID: currentUser.userID,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState),
tradeBanState: user.tradeBanState.toLowerCase() as any,
}
})
// Does not matter if the user is already there or has just been created, just store the credentials
await Credentials.create({ refreshToken, steamID, username })
let teamID: string | undefined
if (wasAdded) {
const rawFirst = (user.name ?? username).trim().split(/\s+/)[0] ?? username;
const firstName = rawFirst
.charAt(0) // first character
.toUpperCase() // make it uppercase
+ rawFirst
.slice(1) // rest of the string
.toLowerCase();
// create a team
teamID = await Team.create({
slug: username,
name: firstName,
ownerID: currentUser.userID,
})
// Add us as a member
await Actor.provide(
"system",
{ teamID },
async () =>
await Member.create({
role: "adult",
userID: currentUser.userID,
steamID
})
)
} else {
// Update the owner of the Steam account
await Steam.updateOwner({ userID: currentUser.userID, steamID })
const t = await Actor.provide(
"user",
currentUser,
async () => {
// Get the team associated with this username
const team = await Team.fromSlug(username);
// This should never happen
if (!team) throw Error(`Is Nestri okay???, we could not find the team with this slug ${username}`)
teamID = team.id
return team.id
}
)
console.log("t",t)
console.log("teamID",teamID)
}
await stream.writeSSE({
event: 'team_slug',
data: JSON.stringify({ username })
})
// Get game library in the background
c.executionCtx.waitUntil((async () => {
const games = await Client.getUserLibrary(accessToken);
// Get a batch of 5 games each
const apps = games?.response?.apps || [];
if (apps.length === 0) {
console.info("[SteamApi] Is Steam okay? No games returned for user:", { steamID });
return
}
const chunkedGames = chunkArray(apps, 5);
// Get the batches to the queue
const processQueue = chunkedGames.map(async (chunk) => {
const myGames = chunk.map(i => {
return {
appID: i.appid,
totalPlaytime: i.rt_playtime,
isFamilyShareable: i.exclude_reason === 0,
lastPlayed: new Date(i.rt_last_played * 1000),
timeAcquired: new Date(i.rt_time_acquired * 1000),
isFamilyShared: !i.owner_steamids.includes(steamID) && i.exclude_reason === 0,
}
})
if (teamID) {
const deduplicationId = crypto
.createHash('md5')
.update(`${teamID}_${chunk.map(g => g.appid).join(',')}`)
.digest('hex');
await Actor.provide(
"member",
{
teamID,
steamID,
userID: currentUser.userID
},
async () => {
const payload = await Library.Events.Queue.create(myGames);
await sqs.send(
new SendMessageCommand({
MessageGroupId: teamID,
QueueUrl: Resource.LibraryQueue.url,
MessageBody: JSON.stringify(payload),
MessageDeduplicationId: deduplicationId,
})
)
}
)
}
})
const settled = await Promise.allSettled(processQueue)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.error("[LibraryQueue] enqueue failed:", (r as PromiseRejectedResult).reason));
})())
await stream.close();
resolve();
})
})
})
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
}
)
}

View File

@@ -1,91 +0,0 @@
import { z } from "zod"
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { Team } from "@nestri/core/team/index";
import { Examples } from "@nestri/core/examples";
import { ErrorResponses, Result, validator } from "./utils";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
export namespace TeamApi {
export const route = new Hono()
.get("/",
describeRoute({
tags: ["Team"],
summary: "List user teams",
description: "List the current user's team details",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.array().openapi({
description: "All team information",
example: [Examples.Team]
})
),
},
},
description: "All team details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
async (c) =>
c.json({
data: await Team.list()
})
)
.get("/:slug",
describeRoute({
tags: ["Team"],
summary: "Get team by slug",
description: "Get the current user's team details, by its slug",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.openapi({
description: "Team details",
example: Examples.Team
})
),
},
},
description: "Team details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
validator(
"param",
z.object({
slug: z.string().openapi({
description: "SLug of the team to get",
example: Examples.Team.slug,
}),
}),
),
async (c) => {
const teamSlug = c.req.valid("param").slug
const team = await Team.fromSlug(teamSlug)
if (!team) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Team ${teamSlug} not found`
)
}
return c.json({
data: team
})
}
)
}

View File

@@ -2,9 +2,9 @@ import { Resource } from "sst";
import { subjects } from "../../subjects";
import { Actor } from "@nestri/core/actor";
import { type MiddlewareHandler } from "hono";
import { Steam } from "@nestri/core/steam/index";
import { createClient } from "@openauthjs/openauth/client";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { Member } from "@nestri/core/member/index";
const client = createClient({
clientID: "api",
@@ -44,19 +44,19 @@ export const auth: MiddlewareHandler = async (c, next) => {
}
if (result.subject.type === "user") {
const teamID = c.req.header("x-nestri-team");
if (!teamID) {
const steamID = c.req.header("x-nestri-steam");
if (!steamID) {
return Actor.provide(result.subject.type, result.subject.properties, next);
}
const userID = result.subject.properties.userID
return Actor.provide(
"system",
"steam",
{
teamID
steamID
},
async () => {
const member = await Member.fromUserID(userID)
if (!member || !member.userID) {
const steamAcc = await Steam.confirmOwnerShip(userID)
if (!steamAcc) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
@@ -66,9 +66,8 @@ export const auth: MiddlewareHandler = async (c, next) => {
return Actor.provide(
"member",
{
steamID: member.steamID,
userID: member.userID,
teamID: member.teamID
steamID,
userID,
},
next)
});

View File

@@ -1,117 +1,238 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Actor } from "@nestri/core/actor";
import { Game } from "@nestri/core/game/index";
import { Steam } from "@nestri/core/steam/index";
import { Client } from "@nestri/core/client/index";
import { Images } from "@nestri/core/images/index";
import { Friend } from "@nestri/core/friend/index";
import { Library } from "@nestri/core/library/index";
import { BaseGame } from "@nestri/core/base-game/index";
import { Credentials } from "@nestri/core/credentials/index";
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
import { Categories } from "@nestri/core/categories/index";
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({});
export const handler = bus.subscriber(
[Credentials.Events.New, BaseGame.Events.New],
[
BaseGame.Events.New,
Steam.Events.Updated,
Steam.Events.Created,
BaseGame.Events.NewBoxArt,
BaseGame.Events.NewHeroArt,
],
async (event) => {
console.log(event.type, event.properties, event.metadata);
switch (event.type) {
case "new_credentials.added": {
const input = event.properties
const credentials = await Credentials.fromSteamID(input.steamID)
if (credentials) {
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
case "new_image.save": {
const input = event.properties;
const image = await Client.getImageInfo({ url: input.url, type: input.type });
session.refreshToken = credentials.refreshToken;
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
const cookies = await session.getWebCookies();
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
const friends = await Client.getFriendsList(cookies);
const putFriends = friends.map(async (user) => {
const wasAdded =
await Steam.create({
id: user.steamID.toString(),
name: user.name,
realName: user.realName,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
tradeBanState: user.tradeBanState.toLowerCase() as any,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState)
}
})
if (!wasAdded) {
console.log(`Steam user ${user.steamID.toString()} already exists`)
}
await Friend.add({ friendSteamID: user.steamID.toString(), steamID: input.steamID })
})
const settled = await Promise.allSettled(putFriends);
settled
.filter(result => result.status === 'rejected')
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
break;
}
case "new_game.added": {
const input = event.properties
// Get images and save to s3
const images = await Client.getImages(input.appID);
(await Promise.allSettled(
images.map(async (image) => {
// Put the images into the db
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
})
))
.filter(i => i.status === "rejected")
.forEach(r => console.warn("[createImages] failed:", (r as PromiseRejectedResult).reason));
break;
}
case "new_box_art_image.save": {
const input = event.properties;
const image = await Client.createBoxArt(input);
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
break;
}
case "new_hero_art_image.save": {
const input = event.properties;
const images = await Client.createHeroArt(input);
const settled =
await Promise.allSettled(
images.map(async (image) => {
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
})
)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[processHeroArt] failed:", (r as PromiseRejectedResult).reason));
break;
}
// case "steam_account.updated":
// case "steam_account.created": {
// //Get user library and commit it to the db
// const steamID = event.properties.steamID;
// await Actor.provide(
// event.metadata.actor.type,
// event.metadata.actor.properties,
// async () => {
// //Get user library
// const gameLibrary = await Client.getUserLibrary(steamID);
// const myLibrary = new Map(gameLibrary.response.games.map(g => [g.appid, g]))
// const queryLib = await Promise.allSettled(
// gameLibrary.response.games.map(async (game) => {
// return await Client.getAppInfo(game.appid.toString())
// })
// )
// queryLib
// .filter(i => i.status === "rejected")
// .forEach(e => console.warn(`[getAppInfo]: Failed to get game metadata: ${e.reason}`))
// const gameInfo = queryLib.filter(i => i.status === "fulfilled").map(f => f.value)
// const queryGames = gameInfo.map(async (game) => {
// await BaseGame.create(game);
// const allCategories = [...game.tags, ...game.genres, ...game.publishers, ...game.developers];
// const uniqueCategories = Array.from(
// new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
// );
// const gameSettled = await Promise.allSettled(
// uniqueCategories.map(async (cat) => {
// //Use a single db transaction to get or set the category
// await Categories.create({
// type: cat.type, slug: cat.slug, name: cat.name
// })
// // Use a single db transaction to get or create the game
// await Game.create({ baseGameID: game.id, categorySlug: cat.slug, categoryType: cat.type })
// })
// )
// gameSettled
// .filter(r => r.status === "rejected")
// .forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason));
// const currentGameInLibrary = myLibrary.get(parseInt(game.id))
// if (currentGameInLibrary) {
// await Library.add({
// baseGameID: game.id,
// lastPlayed: currentGameInLibrary.rtime_last_played ? new Date(currentGameInLibrary.rtime_last_played * 1000) : null,
// totalPlaytime: currentGameInLibrary.playtime_forever,
// })
// } else {
// throw new Error(`Game is not in library, but was found in app info:${game.id}`)
// }
// })
// const settled = await Promise.allSettled(queryGames);
// settled
// .filter(i => i.status === "rejected")
// .forEach(e => console.warn(`[gameCreate]: Failed to create game: ${e.reason}`))
// })
// break;
// }
}
},
);

View File

@@ -1,11 +1,14 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { SQSHandler } from "aws-lambda";
import { Actor } from "@nestri/core/actor";
import { Game } from "@nestri/core/game/index";
import { Utils } from "@nestri/core/client/utils";
import { Client } from "@nestri/core/client/index";
import { Library } from "@nestri/core/library/index";
import { BaseGame } from "@nestri/core/base-game/index";
import { Categories } from "@nestri/core/categories/index";
import { ImageTypeEnum } from "@nestri/core/images/images.sql";
export const handler: SQSHandler = async (event) => {
for (const record of event.Records) {
@@ -17,71 +20,103 @@ export const handler: SQSHandler = async (event) => {
parsed.metadata.actor.type,
parsed.metadata.actor.properties,
async () => {
const processGames = parsed.properties.map(async (game) => {
// First check whether the base_game exists, if not get it
const appID = game.appID.toString()
const exists = await BaseGame.fromID(appID)
const game = parsed.properties
// First check whether the base_game exists, if not get it
const appID = game.appID.toString();
const exists = await BaseGame.fromID(appID);
if (!exists) {
const appInfo = await Client.getAppInfo(appID);
const tags = appInfo.tags;
if (!exists) {
const appInfo = await Client.getAppInfo(appID);
await BaseGame.create({
id: appID,
name: appInfo.name,
size: appInfo.size,
score: appInfo.score,
slug: appInfo.slug,
description: appInfo.description,
releaseDate: appInfo.releaseDate,
primaryGenre: appInfo.primaryGenre,
compatibility: appInfo.compatibility,
controllerSupport: appInfo.controllerSupport,
})
if (game.isFamilyShareable) {
tags.push(Utils.createTag("Family Share"))
}
const allCategories = [...tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers]
const uniqueCategories = Array.from(
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
);
const settled = await Promise.allSettled(
uniqueCategories.map(async (cat) => {
//Use a single db transaction to get or set the category
await Categories.create({
type: cat.type, slug: cat.slug, name: cat.name
})
// Use a single db transaction to get or create the game
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
})
)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason));
}
// Add to user's library
await Library.add({
baseGameID: appID,
lastPlayed: game.lastPlayed,
timeAcquired: game.timeAcquired,
totalPlaytime: game.totalPlaytime,
isFamilyShared: game.isFamilyShared,
await BaseGame.create({
id: appID,
name: appInfo.name,
size: appInfo.size,
slug: appInfo.slug,
links: appInfo.links,
score: appInfo.score,
description: appInfo.description,
releaseDate: appInfo.releaseDate,
primaryGenre: appInfo.primaryGenre,
compatibility: appInfo.compatibility,
controllerSupport: appInfo.controllerSupport,
})
const allCategories = [...appInfo.tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers, ...appInfo.categories, ...appInfo.franchises]
const uniqueCategories = Array.from(
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
);
await Promise.all(
uniqueCategories.map(async (cat) => {
//Create category if it doesn't exist
await Categories.create({
type: cat.type, slug: cat.slug, name: cat.name
})
//Create game if it doesn't exist
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
})
)
const imageUrls = appInfo.images
await Promise.all(
ImageTypeEnum.enumValues.map(async (type) => {
switch (type) {
case "backdrop": {
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "backdrop", url: imageUrls.backdrop })
break;
}
case "banner": {
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "banner", url: imageUrls.banner })
break;
}
case "icon": {
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "icon", url: imageUrls.icon })
break;
}
case "logo": {
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "logo", url: imageUrls.logo })
break;
}
case "poster": {
await bus.publish(
Resource.Bus,
BaseGame.Events.New,
{ appID, type: "poster", url: imageUrls.poster }
)
break;
}
case "heroArt": {
await bus.publish(
Resource.Bus,
BaseGame.Events.NewHeroArt,
{ appID, backdropUrl: imageUrls.backdrop, screenshots: imageUrls.screenshots }
)
break;
}
case "boxArt": {
await bus.publish(
Resource.Bus,
BaseGame.Events.NewBoxArt,
{ appID, logoUrl: imageUrls.logo, backgroundUrl: imageUrls.backdrop }
)
break;
}
}
})
)
}
// Add to user's library
await Library.add({
baseGameID: appID,
lastPlayed: game.lastPlayed ? new Date(game.lastPlayed) : null,
totalPlaytime: game.totalPlaytime,
})
const settled = await Promise.allSettled(processGames)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[processGames] failed:", (r as PromiseRejectedResult).reason));
}
)
})
}
}
}