This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { parseClientVersion } = require('../lib/dota-client-version');
|
||||
|
||||
test('reads ClientVersion from Dota steam.inf', () => {
|
||||
assert.equal(parseClientVersion(`ClientVersion=6888
|
||||
ServerVersion=6888
|
||||
ProductName=dota2_workshop
|
||||
`), 6888);
|
||||
});
|
||||
|
||||
test('rejects a missing or invalid ClientVersion', () => {
|
||||
assert.equal(parseClientVersion('ServerVersion=6888\n'), null);
|
||||
assert.equal(parseClientVersion('ClientVersion=not-a-number\n'), null);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const protobuf = require('protobufjs');
|
||||
|
||||
const proto = protobuf.loadSync(path.join(__dirname, '..', 'dota.proto'));
|
||||
|
||||
test('encodes Source 2 engine on the current CMsgClientHello field 7', () => {
|
||||
const Hello = proto.lookupType('CMsgClientHello');
|
||||
assert.deepEqual([...Hello.encode({ engine: 1 }).finish()], [0x38, 0x01]);
|
||||
});
|
||||
|
||||
test('round-trips a custom practice lobby creation request', () => {
|
||||
const Create = proto.lookupType('CMsgPracticeLobbyCreate');
|
||||
const payload = {
|
||||
passKey: 'secret',
|
||||
lobbyDetails: {
|
||||
gameName: 'Queue helper',
|
||||
serverRegion: 3,
|
||||
gameMode: 15,
|
||||
lan: false,
|
||||
customGameMode: 'addon',
|
||||
customMapName: 'map',
|
||||
customDifficulty: 0,
|
||||
customGameId: '123456789012345678',
|
||||
customMinPlayers: 2,
|
||||
customMaxPlayers: 10,
|
||||
visibility: 0,
|
||||
customGamePenalties: true,
|
||||
},
|
||||
};
|
||||
const decoded = Create.toObject(Create.decode(Create.encode(payload).finish()), { longs: String });
|
||||
assert.equal(decoded.passKey, 'secret');
|
||||
assert.equal(decoded.lobbyDetails.customGameId, '123456789012345678');
|
||||
assert.equal(decoded.lobbyDetails.gameMode, 15);
|
||||
assert.equal(decoded.lobbyDetails.lan, false);
|
||||
assert.equal(decoded.lobbyDetails.customDifficulty, 0);
|
||||
assert.equal(decoded.lobbyDetails.customMaxPlayers, 10);
|
||||
assert.equal(decoded.lobbyDetails.customGamePenalties, true);
|
||||
});
|
||||
|
||||
test('decodes CSODOTALobby type 2004 inside an SO multiple update', () => {
|
||||
const Lobby = proto.lookupType('CSODOTALobby');
|
||||
const Multiple = proto.lookupType('CMsgSOMultipleObjects');
|
||||
const lobbyBytes = Lobby.encode({
|
||||
lobbyId: '987654321012345678',
|
||||
state: 0,
|
||||
leaderId: '76561198000000001',
|
||||
allMembers: [
|
||||
{ id: '76561198000000001', team: 4, slot: 0 },
|
||||
{ id: '76561198000000002', team: 0, slot: 1 },
|
||||
],
|
||||
}).finish();
|
||||
const updateBytes = Multiple.encode({
|
||||
objectsModified: [{ typeId: 2004, objectData: lobbyBytes }],
|
||||
}).finish();
|
||||
const update = Multiple.decode(updateBytes);
|
||||
const decoded = Lobby.toObject(Lobby.decode(update.objectsModified[0].objectData), { longs: String });
|
||||
assert.equal(update.objectsModified[0].typeId, 2004);
|
||||
assert.equal(decoded.lobbyId, '987654321012345678');
|
||||
assert.equal(decoded.allMembers.length, 2);
|
||||
assert.equal(decoded.allMembers[1].id, '76561198000000002');
|
||||
});
|
||||
|
||||
test('decodes reusable custom-game build metadata from a joinable lobby', () => {
|
||||
const Response = proto.lookupType('CMsgJoinableCustomLobbiesResponse');
|
||||
const bytes = Response.encode({
|
||||
lobbies: [{
|
||||
lobbyId: '987654321012345678',
|
||||
customGameId: '2141071809',
|
||||
customMapName: 'duos',
|
||||
customGameTimestamp: 1785566685,
|
||||
customGameCrc: '123456789012345678',
|
||||
minPlayerCount: 2,
|
||||
maxPlayerCount: 12,
|
||||
}],
|
||||
}).finish();
|
||||
const decoded = Response.toObject(Response.decode(bytes), { longs: String });
|
||||
assert.equal(decoded.lobbies[0].customGameId, '2141071809');
|
||||
assert.equal(decoded.lobbies[0].customMapName, 'duos');
|
||||
assert.equal(decoded.lobbies[0].customGameCrc, '123456789012345678');
|
||||
assert.equal(decoded.lobbies[0].customGameTimestamp, 1785566685);
|
||||
});
|
||||
|
||||
test('decodes the internal addon name used to publish a practice lobby', () => {
|
||||
const Response = proto.lookupType('CMsgPracticeLobbyListResponse');
|
||||
const bytes = Response.encode({
|
||||
lobbies: [{
|
||||
id: '987654321012345678',
|
||||
name: 'CHC Duos',
|
||||
customGameMode: 'custom_hero_clash',
|
||||
customMapName: 'duos',
|
||||
maxPlayerCount: 12,
|
||||
serverRegion: 9,
|
||||
penaltiesEnabled: true,
|
||||
}],
|
||||
}).finish();
|
||||
const decoded = Response.toObject(Response.decode(bytes), { longs: String });
|
||||
assert.equal(decoded.lobbies[0].id, '987654321012345678');
|
||||
assert.equal(decoded.lobbies[0].customGameMode, 'custom_hero_clash');
|
||||
assert.equal(decoded.lobbies[0].customMapName, 'duos');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
decideLobbySafety,
|
||||
findAdminMember,
|
||||
normalizeIdentity,
|
||||
toDotaLobbyDetails,
|
||||
validateLobbySettings,
|
||||
} = require('../lib/lobby-settings');
|
||||
const { parseWorkshopPublishData } = require('../lib/workshop-metadata');
|
||||
|
||||
function validSettings(overrides = {}) {
|
||||
return {
|
||||
lobbyName: 'Long queue lobby',
|
||||
serverRegion: 3,
|
||||
customGameId: '123456789012345678',
|
||||
customGameMode: 'my_addon',
|
||||
customMapName: 'my_map',
|
||||
customMinPlayers: 2,
|
||||
customMaxPlayers: 10,
|
||||
notifyAtPlayers: 8,
|
||||
leaveAtPlayers: 9,
|
||||
adminNames: 'Alice, Bob',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('validates per-lobby thresholds excluding the bot slot', () => {
|
||||
const settings = validateLobbySettings(validSettings({ leaveAtPlayers: 9 }));
|
||||
assert.equal(settings.leaveAtPlayers, 9);
|
||||
assert.equal(settings.customMaxPlayers, 10);
|
||||
assert.deepEqual(settings.adminNames, ['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
test('rejects a leave threshold above the custom-game player maximum', () => {
|
||||
assert.throws(
|
||||
() => validateLobbySettings(validSettings({ leaveAtPlayers: 11 })),
|
||||
/cannot exceed the custom-game maximum/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an unreachable leave threshold while the lobby owner occupies a slot', () => {
|
||||
assert.throws(
|
||||
() => validateLobbySettings(validSettings({ leaveAtPlayers: 10 })),
|
||||
/must be below the custom-game maximum/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects notification after auto-leave', () => {
|
||||
assert.throws(
|
||||
() => validateLobbySettings(validSettings({ notifyAtPlayers: 9, leaveAtPlayers: 8 })),
|
||||
/must not exceed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes unicode persona names and accepts SteamID64 as an admin identity', () => {
|
||||
const settings = validateLobbySettings(validSettings({ adminNames: ['Alice', '76561198000000000'] }));
|
||||
const byName = findAdminMember(settings, [{ steamId: '1', name: 'Alice' }]);
|
||||
const byId = findAdminMember(settings, [{ steamId: '76561198000000000', name: 'Renamed' }]);
|
||||
assert.equal(byName.name, 'Alice');
|
||||
assert.equal(byId.name, 'Renamed');
|
||||
assert.equal(normalizeIdentity(' ALICE '), 'alice');
|
||||
});
|
||||
|
||||
test('maps validated settings to current protobuf field names', () => {
|
||||
const settings = validateLobbySettings(validSettings({
|
||||
customGameCrc: '42',
|
||||
customGameTimestamp: '1234',
|
||||
}));
|
||||
assert.deepEqual(toDotaLobbyDetails(settings), {
|
||||
gameName: 'Long queue lobby',
|
||||
serverRegion: 3,
|
||||
gameMode: 15,
|
||||
allowCheats: false,
|
||||
fillWithBots: false,
|
||||
allowSpectating: true,
|
||||
passKey: '',
|
||||
lan: false,
|
||||
customGameMode: 'my_addon',
|
||||
customMapName: 'my_map',
|
||||
customDifficulty: 0,
|
||||
customGameId: '123456789012345678',
|
||||
customMinPlayers: 2,
|
||||
customMaxPlayers: 10,
|
||||
visibility: 0,
|
||||
customGameCrc: '42',
|
||||
customGameTimestamp: 1234,
|
||||
customGamePenalties: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a public custom lobby without its internal addon name', () => {
|
||||
assert.throws(
|
||||
() => validateLobbySettings(validSettings({ customGameMode: '' })),
|
||||
/Internal addon name is required/,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves at the selected real-player count before doing name lookups', () => {
|
||||
const settings = validateLobbySettings(validSettings({ notifyAtPlayers: 2, leaveAtPlayers: 3 }));
|
||||
const members = [
|
||||
{ steamId: '1', name: '' },
|
||||
{ steamId: '2', name: '' },
|
||||
{ steamId: '3', name: '' },
|
||||
];
|
||||
assert.deepEqual(decideLobbySafety(settings, members, 0), { action: 'player-limit', admin: null });
|
||||
});
|
||||
|
||||
test('never recommends an abandon-style leave after lobby launch starts', () => {
|
||||
const settings = validateLobbySettings(validSettings({ notifyAtPlayers: 2, leaveAtPlayers: 3 }));
|
||||
const members = Array.from({ length: 3 }, (_, i) => ({ steamId: String(i + 1), name: '' }));
|
||||
assert.deepEqual(decideLobbySafety(settings, members, 1), { action: 'unsafe-state', admin: null });
|
||||
});
|
||||
|
||||
test('reads the internal addon name from Steam Workshop publish_data', () => {
|
||||
assert.deepEqual(parseWorkshopPublishData(`"publish_data"
|
||||
{
|
||||
"source_folder" "custom_hero_clash"
|
||||
"publish_time" "1785566650"
|
||||
}`), {
|
||||
sourceFolder: 'custom_hero_clash',
|
||||
publishTime: '1785566650',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
TelegramNotifier,
|
||||
formatLobbyMessage,
|
||||
parseRecipientIds,
|
||||
validateBotToken,
|
||||
} = require('../lib/telegram-notifier');
|
||||
|
||||
class MemoryStore {
|
||||
constructor(initial = null) {
|
||||
this.value = initial;
|
||||
}
|
||||
|
||||
load() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
save(value) {
|
||||
this.value = JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
function fakeResponse(result) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, result }),
|
||||
};
|
||||
}
|
||||
|
||||
test('validates Telegram bot tokens and numeric recipient IDs without losing precision', () => {
|
||||
assert.equal(validateBotToken('123456:abcdefghijklmnopqrstuvwxyz_ABC'), '123456:abcdefghijklmnopqrstuvwxyz_ABC');
|
||||
assert.deepEqual(parseRecipientIds('123456789, 4503599627370495; 123456789'), [
|
||||
'123456789',
|
||||
'4503599627370495',
|
||||
]);
|
||||
assert.throws(() => parseRecipientIds('alice'), /positive numeric/);
|
||||
assert.throws(() => parseRecipientIds('4503599627370496'), /positive numeric/);
|
||||
});
|
||||
|
||||
test('formats lobby status with real-player count and resolved member names', () => {
|
||||
const text = formatLobbyMessage(
|
||||
{
|
||||
lobbyName: 'Duos queue',
|
||||
customMapName: 'duos',
|
||||
customMaxPlayers: 12,
|
||||
notifyAtPlayers: 8,
|
||||
leaveAtPlayers: 9,
|
||||
serverRegion: 9,
|
||||
},
|
||||
{
|
||||
phase: 'monitoring',
|
||||
lobbyId: '29963550000000000',
|
||||
humanCount: 2,
|
||||
practicePublished: true,
|
||||
members: [
|
||||
{ steamId: '76561198000000001', name: 'Alice' },
|
||||
{ steamId: '76561198000000002', name: 'Bob' },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(text, /Игроки: 2\/12/);
|
||||
assert.match(text, /1\. Alice \(76561198000000001\)/);
|
||||
assert.match(text, /2\. Bob \(76561198000000002\)/);
|
||||
assert.match(text, /Публикация: в списке лобби/);
|
||||
});
|
||||
|
||||
test('sends one tracker message and edits it only when lobby content changes', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
const method = url.split('/').at(-1);
|
||||
const payload = JSON.parse(options.body);
|
||||
calls.push({ method, payload });
|
||||
if (method === 'getMe') return fakeResponse({ id: 99, username: 'lobby_bot' });
|
||||
if (method === 'sendMessage') return fakeResponse({ message_id: 42 });
|
||||
if (method === 'editMessageText') return fakeResponse({ message_id: 42 });
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
};
|
||||
const store = new MemoryStore();
|
||||
const notifier = new TelegramNotifier({ store, fetchImpl, debounceMs: 1 });
|
||||
await notifier.configure({
|
||||
token: '123456:abcdefghijklmnopqrstuvwxyz_ABC',
|
||||
recipientIds: ['123456789'],
|
||||
});
|
||||
assert.equal(notifier.publicState().configured, true);
|
||||
assert.equal(notifier.publicState().token, undefined);
|
||||
|
||||
notifier.beginLobbySession();
|
||||
const settings = { lobbyName: 'Duos', customMaxPlayers: 12 };
|
||||
const first = {
|
||||
phase: 'monitoring',
|
||||
armed: true,
|
||||
lobbyId: '123',
|
||||
humanCount: 1,
|
||||
members: [{ steamId: '76561198000000001', name: 'Alice' }],
|
||||
};
|
||||
await notifier.syncLobby(settings, first);
|
||||
await notifier.syncLobby(settings, first);
|
||||
const restoredNotifier = new TelegramNotifier({ store, fetchImpl, debounceMs: 1 });
|
||||
await restoredNotifier.syncLobby(settings, {
|
||||
...first,
|
||||
humanCount: 2,
|
||||
members: [...first.members, { steamId: '76561198000000002', name: 'Bob' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.method), ['getMe', 'sendMessage', 'editMessageText']);
|
||||
assert.equal(calls[1].payload.chat_id, '123456789');
|
||||
assert.equal(calls[2].payload.message_id, 42);
|
||||
assert.match(calls[2].payload.text, /Игроки: 2\/12/);
|
||||
assert.equal(store.value.token, '123456:abcdefghijklmnopqrstuvwxyz_ABC');
|
||||
});
|
||||
|
||||
test('test delivery reports recipients that have not started the bot chat', async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
const method = url.split('/').at(-1);
|
||||
if (method === 'getMe') return fakeResponse({ id: 99, username: 'lobby_bot' });
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ ok: false, error_code: 403, description: 'Forbidden: bot can\'t initiate conversation with a user' }),
|
||||
};
|
||||
};
|
||||
const notifier = new TelegramNotifier({ store: new MemoryStore(), fetchImpl });
|
||||
await notifier.configure({
|
||||
token: '123456:abcdefghijklmnopqrstuvwxyz_ABC',
|
||||
recipientIds: ['123456789'],
|
||||
});
|
||||
const result = await notifier.sendTest();
|
||||
assert.equal(result.delivered, 0);
|
||||
assert.equal(result.failed, 1);
|
||||
assert.match(result.errors[0], /can't initiate conversation/);
|
||||
});
|
||||
Reference in New Issue
Block a user