Add Steam lifecycle logging and retry handling

This commit is contained in:
2026-04-16 19:53:52 +08:00
parent d31c4e4c52
commit 2d997efd5e
3 changed files with 648 additions and 60 deletions

View File

@@ -1,8 +1,8 @@
const SteamUser = require('steam-user');
const SteamCommunity = require('steamcommunity');
const fs = require('fs');
const winston = require("winston");
const config = require("./config.js");
const { createSteamLifecycle } = require('./steam-lifecycle');
const logger = winston.createLogger({
level: 'info',
@@ -18,65 +18,14 @@ const users = {};
const steamUser = new SteamUser();
const steamCommunity = new SteamCommunity();
steamUser.setOption("renewRefreshTokens", true);
steamUser.on("refreshToken", (refreshToken) => {
logger.info("Refresh token: " + refreshToken);
fs.writeFileSync('refresh.token', refreshToken);
});
try {
const refreshToken = fs.readFileSync('refresh.token', 'utf8');
if (refreshToken && refreshToken.length > 0) {
const LogOnOptionsAUTO = {
logonID: config.logonID,
refreshToken: refreshToken,
steamID: config.steamID,
};
steamUser.logOn(LogOnOptionsAUTO);
} else {
steamUser.logOn({
accountName: config.accountName,
password: config.password,
logonID: config.logonID,
steamID: config.steamID,
});
}
} catch (e) {
logger.warn(`failed to load session: ${e.message}`)
steamUser.logOn({
accountName: config.accountName,
password: config.password,
logonID: config.logonID,
steamID: config.steamID,
});
}
const steamLoginPromise = new Promise((resolve, reject) => {
steamUser.on('loggedOn', async () => {
logger.info(`login to Steam as ${steamUser.steamID}`);
try {
steamUser.webLogOn();
} catch (err) {
logger.warn(`failed to start web login: ${err.message}`);
}
resolve();
});
});
const steamWebLoginPromise = new Promise((resolve, reject) => {
steamUser.on('webSession', async (sessionID, cookies) => {
logger.info(`web session received: ${sessionID}`);
steamCommunity.setCookies(cookies);
if (config.identitySecret) {
steamCommunity.startConfirmationChecker(10000, config.identitySecret);
}
resolve()
});
const {
steamLoginPromise,
steamWebLoginPromise,
} = createSteamLifecycle({
steamUser,
steamCommunity,
logger,
config,
});
async function getUserInfo(steamID, onUserInfoReceived) {

318
steam-lifecycle.js Normal file
View File

@@ -0,0 +1,318 @@
const fs = require('fs');
const EResult = require('steam-user/enums/EResult');
const DEFAULT_INITIAL_RETRY_DELAY_MS = 5000;
const DEFAULT_MAX_RETRY_DELAY_MS = 5 * 60 * 1000;
const RECOVERABLE_ERESULTS = new Set([
EResult.Invalid,
EResult.Fail,
EResult.NoConnection,
EResult.Busy,
EResult.Timeout,
EResult.ServiceUnavailable,
EResult.TryAnotherCM,
]);
const NON_RECOVERABLE_ERESULTS = new Set([
EResult.InvalidPassword,
EResult.LoggedInElsewhere,
EResult.Banned,
EResult.AccountNotFound,
EResult.InvalidSteamID,
EResult.AccountDisabled,
EResult.AlreadyLoggedInElsewhere,
EResult.Suspended,
EResult.PasswordUnset,
EResult.IllegalPassword,
EResult.AccountLogonDenied,
EResult.InvalidLoginAuthCode,
EResult.AccountLogonDeniedNoMail,
EResult.ExpiredLoginAuthCode,
EResult.IPLoginRestrictionFailed,
EResult.AccountLockedDown,
EResult.AccountLogonDeniedVerifiedEmailRequired,
EResult.RequirePasswordReEntry,
EResult.RateLimitExceeded,
EResult.AccountLoginDeniedNeedTwoFactor,
EResult.AccountLoginDeniedThrottle,
EResult.TwoFactorCodeMismatch,
EResult.TimeNotSynced,
EResult.NeedCaptcha,
EResult.IPBanned,
EResult.LimitedUserAccount,
].filter((value) => typeof value === 'number'));
const NON_RECOVERABLE_MESSAGE_PATTERNS = [
/invalid password/i,
/invalid refresh token/i,
/refreshToken is not/i,
/not valid for logging in/i,
/does not match refreshToken/i,
/steam guard/i,
/two[- ]?factor/i,
/account login denied/i,
/logged in elsewhere/i,
];
const RECOVERABLE_MESSAGE_PATTERNS = [
/no steam servers available/i,
/no connection/i,
/service unavailable/i,
/try another cm/i,
/timeout/i,
/timed out/i,
/econnreset/i,
/econnrefused/i,
/enotfound/i,
/eai_again/i,
/socket/i,
/tls/i,
/network/i,
/rate limit/i,
];
function getEResultName(eresult) {
if (eresult === undefined || eresult === null) {
return undefined;
}
return EResult[eresult] || String(eresult);
}
function buildLogOnOptions(config, fsModule = fs, refreshTokenPath = 'refresh.token') {
try {
const refreshToken = fsModule.readFileSync(refreshTokenPath, 'utf8').trim();
if (refreshToken.length > 0) {
return {
options: {
logonID: config.logonID,
refreshToken,
steamID: config.steamID,
},
mode: 'refreshToken',
};
}
} catch (err) {
// Missing or unreadable refresh tokens are expected on first start.
}
return {
options: {
accountName: config.accountName,
password: config.password,
logonID: config.logonID,
steamID: config.steamID,
},
mode: 'credentials',
};
}
function isRecoverableSteamError(err) {
if (!err) {
return true;
}
const message = String(err.message || err);
if (NON_RECOVERABLE_ERESULTS.has(err.eresult)) {
return false;
}
if (NON_RECOVERABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {
return false;
}
if (RECOVERABLE_ERESULTS.has(err.eresult)) {
return true;
}
if (RECOVERABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {
return true;
}
return false;
}
function createSteamLifecycle({
steamUser,
steamCommunity,
logger,
config,
fsModule = fs,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
refreshTokenPath = 'refresh.token',
initialRetryDelayMs = DEFAULT_INITIAL_RETRY_DELAY_MS,
maxRetryDelayMs = DEFAULT_MAX_RETRY_DELAY_MS,
}) {
let loginResolved = false;
let loginRejected = false;
let retryDelayMs = initialRetryDelayMs;
let retryTimer = null;
let confirmationCheckerStarted = false;
let resolveLogin;
let rejectLogin;
const steamLoginPromise = new Promise((resolve, reject) => {
resolveLogin = resolve;
rejectLogin = reject;
});
let resolveWebLogin;
const steamWebLoginPromise = new Promise((resolve) => {
resolveWebLogin = resolve;
});
function clearRetryTimer() {
if (retryTimer) {
clearTimeoutFn(retryTimer);
retryTimer = null;
}
}
function logOn(reason) {
const { options, mode } = buildLogOnOptions(config, fsModule, refreshTokenPath);
logger.info('steam logon started', {
reason,
mode,
steamID: config.steamID,
});
steamUser.logOn(options);
}
function scheduleRetry(err) {
if (retryTimer) {
logger.warn('steam reconnect already scheduled', {
delayMs: retryDelayMs,
error: err && err.message,
eresult: err && err.eresult,
eresultName: err && getEResultName(err.eresult),
});
return;
}
const delayMs = retryDelayMs;
logger.warn('steam reconnect scheduled', {
delayMs,
error: err && err.message,
eresult: err && err.eresult,
eresultName: err && getEResultName(err.eresult),
});
retryTimer = setTimeoutFn(() => {
retryTimer = null;
retryDelayMs = Math.min(retryDelayMs * 2, maxRetryDelayMs);
try {
logOn('retry');
} catch (retryErr) {
logger.error('steam reconnect attempt failed to start', {
error: retryErr.message,
});
scheduleRetry(retryErr);
}
}, delayMs);
}
steamUser.setOption('renewRefreshTokens', true);
steamUser.on('refreshToken', (refreshToken) => {
logger.info('steam refresh token received');
try {
fsModule.writeFileSync(refreshTokenPath, refreshToken);
} catch (err) {
logger.error('failed to write steam refresh token', {
error: err.message,
});
}
});
steamUser.on('loggedOn', () => {
clearRetryTimer();
retryDelayMs = initialRetryDelayMs;
loginResolved = true;
logger.info(`login to Steam as ${steamUser.steamID}`);
try {
steamUser.webLogOn();
} catch (err) {
logger.warn(`failed to start web login: ${err.message}`);
}
resolveLogin();
});
steamUser.on('disconnected', (eresult, msg) => {
logger.warn('steam disconnected', {
eresult,
eresultName: getEResultName(eresult),
message: msg,
autoRelogin: steamUser.options && steamUser.options.autoRelogin,
});
});
steamUser.on('error', (err) => {
const recoverable = isRecoverableSteamError(err);
logger.error('steam error', {
error: err && err.message,
eresult: err && err.eresult,
eresultName: err && getEResultName(err.eresult),
recoverable,
});
if (recoverable) {
scheduleRetry(err);
return;
}
clearRetryTimer();
if (!loginResolved && !loginRejected) {
loginRejected = true;
rejectLogin(err);
}
});
steamUser.on('webSession', (sessionID, cookies) => {
logger.info(`web session received: ${sessionID}`);
steamCommunity.setCookies(cookies);
if (config.identitySecret && !confirmationCheckerStarted) {
try {
steamCommunity.startConfirmationChecker(10000, config.identitySecret);
confirmationCheckerStarted = true;
} catch (err) {
logger.warn('failed to start confirmation checker', {
error: err.message,
});
}
}
resolveWebLogin();
});
logOn('initial');
return {
steamLoginPromise,
steamWebLoginPromise,
isRecoverableSteamError,
buildLogOnOptions,
getEResultName,
};
}
module.exports = {
DEFAULT_INITIAL_RETRY_DELAY_MS,
DEFAULT_MAX_RETRY_DELAY_MS,
buildLogOnOptions,
createSteamLifecycle,
getEResultName,
isRecoverableSteamError,
};

View File

@@ -0,0 +1,321 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const EResult = require('steam-user/enums/EResult');
const {
buildLogOnOptions,
createSteamLifecycle,
isRecoverableSteamError,
} = require('../steam-lifecycle');
class FakeSteamUser extends EventEmitter {
constructor() {
super();
this.options = { autoRelogin: true };
this.logOnCalls = [];
this.webLogOnCalls = 0;
this.steamID = null;
}
setOption(name, value) {
this.options[name] = value;
}
logOn(options) {
this.logOnCalls.push(options);
}
webLogOn() {
this.webLogOnCalls += 1;
}
}
function createLogger() {
const entries = [];
return {
entries,
info(message, meta) {
entries.push({ level: 'info', message, meta });
},
warn(message, meta) {
entries.push({ level: 'warn', message, meta });
},
error(message, meta) {
entries.push({ level: 'error', message, meta });
},
};
}
function createFsModule(token = '') {
const writes = [];
return {
writes,
readFileSync(path, encoding) {
assert.equal(path, 'refresh.token');
assert.equal(encoding, 'utf8');
if (!token) {
const err = new Error('missing');
err.code = 'ENOENT';
throw err;
}
return token;
},
writeFileSync(path, data) {
writes.push({ path, data });
},
};
}
function createTimerControls() {
const timers = [];
return {
timers,
setTimeoutFn(fn, delayMs) {
const timer = { fn, delayMs, cleared: false };
timers.push(timer);
return timer;
},
clearTimeoutFn(timer) {
timer.cleared = true;
},
runNextTimer() {
const timer = timers.find((item) => !item.cleared && !item.ran);
assert.ok(timer, 'expected a pending timer');
timer.ran = true;
timer.fn();
return timer;
},
};
}
function createLifecycle(overrides = {}) {
const steamUser = overrides.steamUser || new FakeSteamUser();
const steamCommunity = overrides.steamCommunity || {
cookies: [],
checkerCalls: [],
setCookies(cookies) {
this.cookies.push(cookies);
},
startConfirmationChecker(intervalMs, identitySecret) {
this.checkerCalls.push({ intervalMs, identitySecret });
},
};
const logger = overrides.logger || createLogger();
const fsModule = overrides.fsModule || createFsModule(overrides.refreshToken);
const timerControls = overrides.timerControls || createTimerControls();
const config = {
accountName: 'account',
password: 'password',
logonID: 123,
steamID: 'self-id',
identitySecret: 'identity-secret',
...overrides.config,
};
const lifecycle = createSteamLifecycle({
steamUser,
steamCommunity,
logger,
config,
fsModule,
setTimeoutFn: timerControls.setTimeoutFn,
clearTimeoutFn: timerControls.clearTimeoutFn,
initialRetryDelayMs: 10,
maxRetryDelayMs: 40,
});
return {
lifecycle,
steamUser,
steamCommunity,
logger,
fsModule,
timerControls,
};
}
test('buildLogOnOptions uses refresh token when available', () => {
const fsModule = createFsModule(' token-value \n');
assert.deepEqual(buildLogOnOptions({
accountName: 'account',
password: 'password',
logonID: 7,
steamID: 'self-id',
}, fsModule), {
mode: 'refreshToken',
options: {
logonID: 7,
refreshToken: 'token-value',
steamID: 'self-id',
},
});
});
test('buildLogOnOptions falls back to credentials without a refresh token', () => {
const fsModule = createFsModule('');
assert.deepEqual(buildLogOnOptions({
accountName: 'account',
password: 'password',
logonID: 7,
steamID: 'self-id',
}, fsModule), {
mode: 'credentials',
options: {
accountName: 'account',
password: 'password',
logonID: 7,
steamID: 'self-id',
},
});
});
test('steam disconnected logs warning and lets steam-user autoRelogin handle reconnect', () => {
const { steamUser, logger } = createLifecycle();
assert.equal(steamUser.logOnCalls.length, 1);
steamUser.emit('disconnected', EResult.NoConnection, 'connection closed');
assert.equal(steamUser.logOnCalls.length, 1);
assert.deepEqual(logger.entries.find((entry) => entry.message === 'steam disconnected'), {
level: 'warn',
message: 'steam disconnected',
meta: {
eresult: EResult.NoConnection,
eresultName: 'NoConnection',
message: 'connection closed',
autoRelogin: true,
},
});
});
test('recoverable steam errors schedule a single retry and re-log on', () => {
const { steamUser, logger, timerControls } = createLifecycle({
refreshToken: 'refresh-token',
});
const err = new Error('No Steam servers available');
err.eresult = EResult.NoConnection;
steamUser.emit('error', err);
steamUser.emit('error', err);
assert.equal(timerControls.timers.length, 1);
assert.equal(timerControls.timers[0].delayMs, 10);
assert.equal(steamUser.logOnCalls.length, 1);
assert.equal(logger.entries.filter((entry) => entry.message === 'steam reconnect scheduled').length, 1);
assert.equal(logger.entries.filter((entry) => entry.message === 'steam reconnect already scheduled').length, 1);
timerControls.runNextTimer();
assert.equal(steamUser.logOnCalls.length, 2);
assert.deepEqual(steamUser.logOnCalls[1], {
logonID: 123,
refreshToken: 'refresh-token',
steamID: 'self-id',
});
});
test('non-recoverable initial steam errors reject login and do not retry', async () => {
const { lifecycle, steamUser, timerControls } = createLifecycle();
const err = new Error('InvalidPassword');
err.eresult = EResult.InvalidPassword;
steamUser.emit('error', err);
await assert.rejects(lifecycle.steamLoginPromise, err);
assert.equal(timerControls.timers.length, 0);
});
test('loggedOn clears pending retry, resets backoff, resolves login, and starts web login', async () => {
const { lifecycle, steamUser, timerControls } = createLifecycle({
refreshToken: 'refresh-token',
});
const err = new Error('timeout');
err.eresult = EResult.Timeout;
steamUser.emit('error', err);
assert.equal(timerControls.timers.length, 1);
steamUser.steamID = 'self-id';
steamUser.emit('loggedOn');
await lifecycle.steamLoginPromise;
assert.equal(timerControls.timers[0].cleared, true);
assert.equal(steamUser.webLogOnCalls, 1);
steamUser.emit('error', err);
assert.equal(timerControls.timers.length, 2);
assert.equal(timerControls.timers[1].delayMs, 10);
});
test('webSession refreshes cookies repeatedly but starts confirmation checker once', async () => {
const { lifecycle, steamUser, steamCommunity } = createLifecycle();
steamUser.emit('webSession', 'session-1', ['cookie-1']);
steamUser.emit('webSession', 'session-2', ['cookie-2']);
await lifecycle.steamWebLoginPromise;
assert.deepEqual(steamCommunity.cookies, [['cookie-1'], ['cookie-2']]);
assert.deepEqual(steamCommunity.checkerCalls, [{
intervalMs: 10000,
identitySecret: 'identity-secret',
}]);
});
test('refreshToken writes token and logs write failures without creating connection log files', () => {
const fsModule = createFsModule();
const logger = createLogger();
const { steamUser } = createLifecycle({ fsModule, logger });
steamUser.emit('refreshToken', 'new-token');
assert.deepEqual(fsModule.writes, [{
path: 'refresh.token',
data: 'new-token',
}]);
const failingFsModule = {
readFileSync() {
const err = new Error('missing');
err.code = 'ENOENT';
throw err;
},
writeFileSync() {
throw new Error('disk full');
},
};
const failingLogger = createLogger();
const { steamUser: failingSteamUser } = createLifecycle({
fsModule: failingFsModule,
logger: failingLogger,
});
failingSteamUser.emit('refreshToken', 'new-token');
assert.equal(
failingLogger.entries.some((entry) => entry.message === 'failed to write steam refresh token'),
true
);
});
test('isRecoverableSteamError classifies expected recoverable and fatal errors', () => {
const networkErr = new Error('socket disconnected before secure TLS connection was established');
networkErr.eresult = EResult.Fail;
assert.equal(isRecoverableSteamError(networkErr), true);
const authErr = new Error('InvalidPassword');
authErr.eresult = EResult.InvalidPassword;
assert.equal(isRecoverableSteamError(authErr), false);
const replacedSessionErr = new Error('LoggedInElsewhere');
replacedSessionErr.eresult = EResult.LoggedInElsewhere;
assert.equal(isRecoverableSteamError(replacedSessionErr), false);
const throttledErr = new Error('AccountLoginDeniedThrottle');
throttledErr.eresult = EResult.AccountLoginDeniedThrottle;
assert.equal(isRecoverableSteamError(throttledErr), false);
});