Skip to content
This repository was archived by the owner on Sep 20, 2024. It is now read-only.

Commit 685c8dd

Browse files
committedApr 28, 2019
Added support for Group CHAT (WIP)
1 parent 03372ba commit 685c8dd

File tree

4 files changed

+361
-9
lines changed

4 files changed

+361
-9
lines changed
 

‎README.md

+23-5
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
* development: new code which will hopefully end up in master.
77
* nodb: this bot instance doesn't use Mongo DB so it doesn't do price checking for lotteries.
88
* donation-only: this bot instance only accepts all donations and all kind of offers from bot's owner (doesn't use Mongo DB and doesn't do lotteries).
9-
* donation-only-without-2FA: this bot instance only accepts donations (no item loss) and doesn't use Mobile AUth (doesn't use Mongo DB and doesn't do lotteries).
9+
* donation-only-without-2FA: this bot instance only accepts donations (no item loss) and doesn't use Mobile Auth (doesn't use Mongo DB and doesn't do lotteries).
1010

1111
***
1212

13-
**_Steam Trading BoT | Handles Trade Offers and Friends Invites_**
13+
**_Steam Trading/CHAT BoT | Handles Trade Offers, Friends Invites and Group CHATs_**
1414

1515
The following is a list of functions that ZED provides at the moment:
1616

@@ -23,6 +23,7 @@ The following is a list of functions that ZED provides at the moment:
2323
* Chat messages check (logs to console when bot receives a chat message).
2424
* Notifications check (logs to console if any new comment is available).
2525
* Lottery: Send any 1 Trading Card, Background, Emoticon or Booster Pack to the BoT and it will send you back a random Item of the same type (card for card, emote for emote etc...).
26+
* Group CHAT: Join the BoT to any Group CHAT manually or via config file and start using commands (WIP).
2627

2728

2829
## Required software
@@ -36,6 +37,7 @@ The following is a list of functions that ZED provides at the moment:
3637
* chalk
3738
* console-stamp
3839
* mongodb
40+
* request
3941
* steam-totp
4042
* steam-tradeoffer-manager
4143
* steam-user
@@ -51,7 +53,7 @@ Run `npm install` inside BoT's directory to install all dependencies.
5153

5254
`password`: bot's Steam Account Password
5355

54-
`sharedSecret`: You might be wondering where to find the shared/identity secret and there are actually many tutorials depending on your device [1]
56+
`sharedSecret`: You might be wondering where to find the shared/identity secret and there are actually many tutorials depending on your device. [1]
5557

5658
`identitySecret`: Same as above.
5759

@@ -63,6 +65,8 @@ Run `npm install` inside BoT's directory to install all dependencies.
6365

6466
`botSteamID3`: See Above
6567

68+
`ClanChatGroupID`: Your Group's ID64 to have the BoT automatically join it. [2]
69+
6670
`customGame`: This is Non-Steam Game Played by the BoT - Something like "Trash BoT - Accepting Junk" or whatever you want.
6771

6872
`lockedItems`: This is an array of Items you don't want to be traded by the BoT (maybe you're using them in your profile, like a background).
@@ -73,11 +77,16 @@ Run `npm install` inside BoT's directory to install all dependencies.
7377

7478
`syncInventoryWithDbOnStartup`: Update DB entries syncing them with bot's Inventory (you can also sync manually with Admin-Only "!sync" command).
7579

76-
`db "connectionString"`: This depends on your MongoDB Configuration - Something like this should work: "mongodb://localhost:27017/zed"
80+
`openweathermapAPI`: Not mandatory but "!weather" command won't work without it. If you want to get one for free, visit: "https://openweathermap.org/api".
81+
82+
`steamAPI`: Not mandatory but "!tf2" command won't work without it. You can get yours for free by visiting "https://steamcommunity.com/dev/apikey".
83+
84+
`db "connectionString"`: This depends on your MongoDB Configuration - Something like this should work: "mongodb://localhost:27017/zed".
7785

7886

7987
[1] Shared/Identity secrets must be extracted from your Two Factor Authenticator App, so it's always a different process depending on which device
8088
you're actually using: iPhone - Android - 3rd Party Desktop App like WinAuth etc...
89+
[2] You can check steamID of your group by navigating to its page, then adding /memberslistxml?xml=1 to the end of the link, so the link will look like this: "https://steamcommunity.com/groups/wrongditch/memberslistxml?xml=1". Then you can get steamID of your group from the result, it's in <groupID64> tag.
8190

8291
## Starting the BoT
8392

@@ -91,4 +100,13 @@ you're actually using: iPhone - Android - 3rd Party Desktop App like WinAuth etc
91100

92101
### Admin-Only
93102

94-
`!sync`: Sync DB and Inventory Items manually.
103+
`!sync`: Sync DB and Inventory Items manually.
104+
105+
## Group CHAT Commands
106+
107+
`!help` || `!commands`: You'll get a list of all commands.
108+
109+
### Featured Commands
110+
111+
`!weather <city> <unit of measure>`: Query "openweathermaps" to get weather info for chosen city in Metric or Imperial units.
112+
`!tf2 <class>`: Get personal Team Fortress 2 Stats for chosen class.

‎app/client.js

+331-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ const zed = require('./main');
44
const config = require('../config.json');
55
const SteamUser = require('steam-user');
66

7-
//console colors
87
const chalk = require('chalk');
8+
const request = require('request');
99

1010
zed.manager._steam.on('loggedOn', function (details) {
1111
if (details.eresult === SteamUser.EResult.OK) {
@@ -26,6 +26,25 @@ zed.manager._steam.on('loggedOn', function (details) {
2626
});
2727
zed.manager._steam.setPersona(5); //"5": "LookingToTrade" -- https://github.com/DoctorMcKay/node-steam-user/blob/master/enums/EPersonaState.js
2828
zed.manager._steam.gamesPlayed(zed.config.customGame);
29+
30+
31+
//Join GROUP CHAT
32+
if (zed.config.ClanChatGroup) {
33+
zed.manager._steam.chat.getClanChatGroupInfo(zed.config.ClanChatGroup, function (err, response) {
34+
if (!err) {
35+
//console.log(response);
36+
zed.manager._steam.chat.joinGroup(response.chat_group_summary.chat_group_id, function (err, response) {
37+
if (err) {
38+
console.log(err);
39+
}
40+
});
41+
} else {
42+
console.log(err);
43+
}
44+
});
45+
}
46+
47+
2948
} else {
3049
console.log(details);
3150
//Do whatever u want to handle the error...
@@ -132,6 +151,29 @@ zed.manager._steam.on('friendRelationship', (steamID, relationship) => {
132151
}
133152
});
134153

154+
155+
156+
//New Group Chat Message
157+
//This will fire when a new chat message gets posted in Group Chat we're in
158+
zed.manager._steam.chat.on('chatMessage', function (message) {
159+
var senderID = message.steamid_sender;
160+
var senderAccountID = senderID.accountid;
161+
162+
//console.log(message);
163+
164+
zed.manager._steam.getPersonas([senderID], function (err, personas) {
165+
if (!err) {
166+
var sender = personas[senderID]["player_name"];
167+
parseMessage(message.chat_group_id, message.chat_id, message.message_no_bbcode, senderID, senderAccountID, sender);
168+
} else {
169+
console.log(err);
170+
}
171+
});
172+
173+
});
174+
175+
176+
135177
//Commands
136178
zed.manager._steam.on('friendMessage', function (steamID, message) {
137179
if (message.startsWith('[tradeoffer sender=')) {
@@ -164,4 +206,291 @@ zed.manager._steam.on('friendMessage', function (steamID, message) {
164206
else {
165207
zed.manager._steam.chatMessage(steamID, 'I don\'t understand any other command but "!help", "!sign" and "!lottery" (so far).');
166208
}
167-
});
209+
});
210+
211+
212+
async function parseMessage(groupID, chatID, message, senderID, senderAccountID, sender) {
213+
if (!message || !message.startsWith('!')) {
214+
return;
215+
}
216+
217+
/*
218+
if (server_message && server_message.message == 2) {
219+
zed.manager._steam.getPersonas([server_message.steamid_param], function (err, personas) {
220+
if (!err) {
221+
var joined = personas[server_message.steamid_param]["player_name"];
222+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Welcome aboard " + "[mention=" + server_message.steamid_param.accountid + "]@" + joined + "[/mention]" + "!" + " :steamhappy:");
223+
} else {
224+
console.log(err);
225+
return;
226+
}
227+
});
228+
}
229+
*/
230+
231+
if (message === "!hello") {
232+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Hi there " + "[mention=" + senderAccountID + "]@" + sender + "[/mention]" + "!" + " :steamhappy:"); // [mention=accountid]@name[/mention]
233+
} else if (message === "!next") {
234+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Your satisfaction is our best prize. Next!");
235+
} else if (message === "!help") {
236+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "I'm a trading and chat bot; if you want to trade with me, first read the info showcase on my profile. For a list of available commands, type '!commands' without the quotes. More at: https://github.com/roughnecks/ZED" );
237+
} else if (message === "!commands") {
238+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "!commands - !hello - !help - !next - !weather <city> <metric || imperial> - !tf2 <class>");
239+
} else if (message.startsWith('!weather')) {
240+
var str = message.substr(9);
241+
var res = str.split(" ");
242+
243+
if (res.length > 1) {
244+
var units = res[res.length - 1];
245+
units = units.toUpperCase();
246+
res.length = res.length - 1;
247+
var city = res.join(' ');
248+
checkWeather(city, units, groupID, chatID);
249+
} else { zed.manager._steam.chat.sendChatMessage(groupID, chatID, "You must specify a city and a unit of measure, either 'metric' or 'imperial'."); }
250+
251+
252+
} else if (message.startsWith('!tf2')) {
253+
var tf2class = message.substr(5);
254+
if (tf2class) {
255+
tf2Stats(tf2class, groupID, chatID, sender, senderID);
256+
} else { zed.manager._steam.chat.sendChatMessage(groupID, chatID, "You must specify a class."); }
257+
}
258+
}
259+
260+
261+
async function checkWeather(city, units, groupID, chatID) {
262+
263+
var url;
264+
var apiKey = zed.config.weatherAPI;
265+
266+
if (apiKey) {
267+
if (units === 'METRIC') {
268+
url = `http://api.openweathermap.org/data/2.5/weather?q=${city}&units=${units}&appid=${apiKey}`;
269+
} else if (units === 'IMPERIAL') {
270+
url = `http://api.openweathermap.org/data/2.5/weather?q=${city}&units=${units}&appid=${apiKey}`;
271+
}
272+
else {
273+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Wrong or missing unit of measure.");
274+
return;
275+
}
276+
277+
request(url, function (err, response, body) {
278+
if (err) {
279+
console.log('error:', error);
280+
} else {
281+
let weather = JSON.parse(body);
282+
if (weather.cod == 404) {
283+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "City not found.");
284+
return;
285+
} else if (weather.cod == 200) {
286+
if (units === 'METRIC') {
287+
let result = `It's ${weather.weather[0].description} and ${weather.main.temp} °C in ${weather.name}, ${weather.sys.country}! Pressure is ${weather.main.pressure} hPa, humidity is ${weather.main.humidity}% and wind speed is ${weather.wind.speed} meter/sec.`;
288+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, result);
289+
} else if (units == 'IMPERIAL') {
290+
let result = `It's ${weather.weather[0].description} and ${weather.main.temp} °F in ${weather.name}, ${weather.sys.country}! Pressure is ${weather.main.pressure} hPa, humidity is ${weather.main.humidity}% and wind speed is ${weather.wind.speed} miles/hour.`;
291+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, result);
292+
}
293+
}
294+
else {
295+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Houston, we have a problem!");
296+
console.log(weather);
297+
}
298+
}
299+
});
300+
} else {
301+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "No API Key defined in config file, aborting.")
302+
return;
303+
}
304+
}
305+
306+
307+
async function tf2Stats(tf2class, groupID, chatID, sender, senderID) {
308+
var apikey = zed.config.steamAPI;
309+
310+
if (apikey) {
311+
var playerID64 = senderID.getSteamID64();
312+
var player = sender;
313+
var tf2classLower = tf2class.toLowerCase();
314+
var tf2classCapitalized = tf2classLower.charAt(0).toUpperCase() + tf2classLower.slice(1);
315+
316+
var url = `http://api.steampowered.com/ISteamUserStats/GetUserStatsForGame/v0002/?appid=440&key=${apikey}&steamid=${playerID64}`;
317+
318+
request(url, function (error, response, body) {
319+
if (error) {
320+
console.log('error: '+ error);
321+
} else {
322+
//console.log(response.statusCode);
323+
324+
if (response.statusCode == 500) {
325+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Your Game Details are not Public.");
326+
return;
327+
} else if (response.statusCode == 200) {
328+
let output = JSON.parse(body);
329+
//console.log(JSON.stringify(output))
330+
331+
var accumBuild, maxBuild, accumDam, maxDam, accumDom, maxDom, accumKAss, maxKAss, accumKills, maxKills, accHours, maxMins, maxSecs, accumCap, maxCap, accumDef, maxDef, accumPoints, maxPoints, accumRev, maxRev, accumBack, maxBack, accumLeach, maxLeach, accumBuilt, maxBuilt, accumTel, maxTel, maxSentry, accumHeadS, maxHeadS, accumHeal, maxHeal, accumInvul, maxInvul;
332+
accumBuild = maxBuild = accumDam = maxDam = accumDom = maxDom = accumKAss = maxKAss = accumKills = maxKills = accHours = maxMins = maxSecs = accumCap = maxCap = accumDef = maxDef = accumPoints = maxPoints = accumRev = maxRev = accumBack = maxBack = accumLeach = maxLeach = accumBuilt = maxBuilt = accumTel = maxTel = maxSentry = accumHeadS = maxHeadS = accumHeal = maxHeal = accumInvul = maxInvul = "-";
333+
334+
var stats = output.playerstats.stats;
335+
for (var i = 0; i < stats.length; i++) {
336+
if (stats[i].name == tf2classCapitalized + ".accum.iBuildingsDestroyed") {
337+
accumBuild = stats[i].value;
338+
}
339+
else if (stats[i].name == tf2classCapitalized + ".max.iBuildingsDestroyed") {
340+
maxBuild = stats[i].value;
341+
}
342+
else if (stats[i].name == tf2classCapitalized + ".accum.iDamageDealt") {
343+
accumDam = stats[i].value;
344+
}
345+
else if (stats[i].name == tf2classCapitalized + ".max.iDamageDealt") {
346+
maxDam = stats[i].value;
347+
}
348+
else if (stats[i].name == tf2classCapitalized + ".accum.iDominations") {
349+
accumDom = stats[i].value;
350+
}
351+
else if (stats[i].name == tf2classCapitalized + ".max.iDominations") {
352+
maxDom = stats[i].value;
353+
}
354+
else if (stats[i].name == tf2classCapitalized + ".accum.iKillAssists") {
355+
accumKAss = stats[i].value;
356+
}
357+
else if (stats[i].name == tf2classCapitalized + ".max.iKillAssists") {
358+
maxKAss = stats[i].value;
359+
}
360+
else if (stats[i].name == tf2classCapitalized + ".accum.iNumberOfKills") {
361+
accumKills = stats[i].value;
362+
}
363+
else if (stats[i].name == tf2classCapitalized + ".max.iNumberOfKills") {
364+
maxKills = stats[i].value;
365+
}
366+
else if (stats[i].name == tf2classCapitalized + ".accum.iPlayTime") {
367+
var accumTime = stats[i].value;
368+
accHours = Math.round(accumTime * 100 / 3600) / 100;
369+
}
370+
else if (stats[i].name == tf2classCapitalized + ".max.iPlayTime") {
371+
var maxTime = stats[i].value;
372+
maxMins = Math.floor(maxTime / 60);
373+
maxSecs = maxTime - maxMins * 60;
374+
}
375+
else if (stats[i].name == tf2classCapitalized + ".accum.iPointCaptures") {
376+
accumCap = stats[i].value;
377+
}
378+
else if (stats[i].name == tf2classCapitalized + ".max.iPointCaptures") {
379+
maxCap = stats[i].value;
380+
}
381+
else if (stats[i].name == tf2classCapitalized + ".accum.iPointDefenses") {
382+
accumDef = stats[i].value;
383+
}
384+
else if (stats[i].name == tf2classCapitalized + ".max.iPointDefenses") {
385+
maxDef = stats[i].value;
386+
}
387+
else if (stats[i].name == tf2classCapitalized + ".accum.iPointsScored") {
388+
accumPoints = stats[i].value;
389+
}
390+
else if (stats[i].name == tf2classCapitalized + ".max.iPointsScored") {
391+
maxPoints = stats[i].value;
392+
}
393+
else if (stats[i].name == tf2classCapitalized + ".accum.iRevenge") {
394+
accumRev = stats[i].value;
395+
}
396+
else if (stats[i].name == tf2classCapitalized + ".max.iRevenge") {
397+
maxRev = stats[i].value;
398+
}
399+
else if (stats[i].name == tf2classCapitalized + ".accum.iBackstabs") {
400+
accumBack = stats[i].value;
401+
}
402+
else if (stats[i].name == tf2classCapitalized + ".max.iBackstabs") {
403+
maxBack = stats[i].value;
404+
}
405+
else if (stats[i].name == tf2classCapitalized + ".accum.iHealthPointsLeached") {
406+
accumLeach = stats[i].value;
407+
}
408+
else if (stats[i].name == tf2classCapitalized + ".max.iHealthPointsLeached") {
409+
maxLeach = stats[i].value;
410+
}
411+
else if (stats[i].name == tf2classCapitalized + ".accum.iBuildingsBuilt") {
412+
accumBuilt = stats[i].value;
413+
}
414+
else if (stats[i].name == tf2classCapitalized + ".max.iBuildingsBuilt") {
415+
maxBuilt = stats[i].value;
416+
}
417+
else if (stats[i].name == tf2classCapitalized + ".accum.iNumTeleports") {
418+
accumTel = stats[i].value;
419+
}
420+
else if (stats[i].name == tf2classCapitalized + ".max.iNumTeleports") {
421+
maxTel = stats[i].value;
422+
}
423+
else if (stats[i].name == tf2classCapitalized + ".max.iSentryKills") {
424+
maxSentry = stats[i].value;
425+
}
426+
else if (stats[i].name == tf2classCapitalized + ".accum.iHeadshots") {
427+
accumHeadS = stats[i].value;
428+
}
429+
else if (stats[i].name == tf2classCapitalized + ".max.iHeadshots") {
430+
maxHeadS = stats[i].value;
431+
}
432+
else if (stats[i].name == tf2classCapitalized + ".accum.iHealthPointsHealed") {
433+
accumHeal = stats[i].value;
434+
}
435+
else if (stats[i].name == tf2classCapitalized + ".max.iHealthPointsHealed") {
436+
maxHeal = stats[i].value;
437+
}
438+
else if (stats[i].name == tf2classCapitalized + ".accum.iNumInvulnerable") {
439+
accumInvul = stats[i].value;
440+
}
441+
else if (stats[i].name == tf2classCapitalized + ".max.iNumInvulnerable") {
442+
maxInvul = stats[i].value;
443+
}
444+
}
445+
} else {
446+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Unknown Error");
447+
console.log('Response Status Code = ' + response.statusCode);
448+
console.log('Body = ' + body);
449+
return;
450+
}
451+
452+
let result = ":sticky:" + `${tf2classCapitalized} Stats for Player "${player}":` + "\n" + "\n"
453+
+ "Total Playtime / Longest Life: " + `${accHours}hrs` + " / " + `${maxMins}:${maxSecs}mins` + "\n"
454+
+ "Total / Most Points: " + accumPoints + " / " + maxPoints + "\n"
455+
+ "Total / Most Kills: " + accumKills + " / " + maxKills + "\n"
456+
+ "Total / Most Damage Dealt: " + accumDam + " / " + maxDam + "\n"
457+
+ "Total / Most Kill Assists: " + accumKAss + " / " + maxKAss + "\n"
458+
+ "Total / Most Dominations: " + accumDom + " / " + maxDom + "\n"
459+
+ "Total / Most Revenges: " + accumRev + " / " + maxRev + "\n"
460+
+ "Total / Most Buildings Destroyed: " + accumBuild + " / " + maxBuild + "\n"
461+
+ "Total / Most Captures: " + accumCap + " / " + maxCap + "\n"
462+
+ "Total / Most Defenses: " + accumDef + " / " + maxDef;
463+
464+
465+
if ((tf2classCapitalized === 'Demoman') || (tf2classCapitalized === 'Soldier') || (tf2classCapitalized === 'Pyro') || (tf2classCapitalized === 'Heavy') || (tf2classCapitalized === 'Scout')) {
466+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, result);
467+
} else if (tf2classCapitalized === 'Medic') {
468+
let medicResult = result + "\n"
469+
+ "Total / Most Points Healed: " + accumHeal + " / " + maxHeal + "\n"
470+
+ "Total / Most ÜberCharges: " + accumInvul + " / " + maxInvul;
471+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, medicResult);
472+
} else if (tf2classCapitalized === 'Engineer') {
473+
let engiResult = result + "\n"
474+
+ "Total / Most Buildings Built: " + accumBuilt + " / " + maxBuilt + "\n"
475+
+ "Total / Most Teleports: " + accumTel + " / " + maxTel + "\n"
476+
+ "Most Kills By Sentry: " + maxSentry;
477+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, engiResult);
478+
} else if (tf2classCapitalized === 'Spy') {
479+
let spyResult = result + "\n"
480+
+ "Total / Most Backstabs: " + accumBack + " / " + maxBack + "\n"
481+
+ "Total / Most Health Points Leached: " + accumLeach + " / " + maxLeach;
482+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, spyResult);
483+
} else if (tf2classCapitalized === 'Sniper') {
484+
let snipResult = result + "\n"
485+
+ "Total / Most Headshots: " + accumHeadS + " / " + maxHeadS;
486+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, snipResult);
487+
}
488+
else {zed.manager._steam.chat.sendChatMessage(groupID, chatID, "Invalid class, moron!")};
489+
}
490+
});
491+
} else {
492+
zed.manager._steam.chat.sendChatMessage(groupID, chatID, "No API Key defined in config file, aborting.")
493+
return;
494+
}
495+
}
496+

‎app/main.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ const zed = {
1818
ownerSteamID3: config.ownerSteamID3,
1919
botSteamID64: config.botSteamID64,
2020
botSteamID3: config.botSteamID3,
21+
ClanChatGroup: config.ClanChatGroupID,
2122
customGame: config.customGame,
22-
pin: config.familyViewPin,
23-
//Items that bot owns and are not up for trading
23+
pin: config.familyViewPin,
2424
lockedItems: config.lockedItems,
25+
weatherAPI: config.openweathermapAPI,
26+
steamAPI: config.steamAPI,
2527
logOnOptions: {
2628
accountName: config.username,
2729
password: config.password,

‎config.json

+3
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
"ownerSteamID3": "",
88
"botSteamID64": "",
99
"botSteamID3": "",
10+
"ClanChatGroupID": "",
1011
"customGame": "",
1112
"lockedItems": [],
1213
"familyViewPin": "",
1314
"updatePricesOnStartup": false,
1415
"syncInventoryWithDbOnStartup": true,
16+
"openweathermapAPI": "",
17+
"steamAPI": "",
1518
"db": {
1619
"connectionString": ""
1720
}

0 commit comments

Comments
 (0)
This repository has been archived.