diff --git a/classes/CSteamDiscussion.js b/classes/CSteamDiscussion.js
new file mode 100644
index 0000000..b6706b2
--- /dev/null
+++ b/classes/CSteamDiscussion.js
@@ -0,0 +1,221 @@
+const Cheerio = require('cheerio');
+const SteamID = require('steamid');
+const StdLib = require('@doctormckay/stdlib');
+
+const SteamCommunity = require('../index.js');
+const Helpers = require('../components/helpers.js');
+
+const EDiscussionType = require('../resources/EDiscussionType.js');
+
+
+/**
+ * Scrape a discussion's DOM to get all available information
+ * @param {string} url - SteamCommunity url pointing to the discussion to fetch
+ * @param {function} callback - First argument is null/Error, second is object containing all available information
+ */
+SteamCommunity.prototype.getSteamDiscussion = function(url, callback) {
+ // Construct object holding all the data we can scrape
+ let discussion = {
+ id: null,
+ type: null,
+ appID: null,
+ forumID: null,
+ gidforum: null, // This is some id used as parameter 2 in post requests
+ topicOwner: null, // This is some id used as parameter 1 in post requests
+ author: null,
+ postedDate: null,
+ title: null,
+ content: null,
+ commentsAmount: null, // I originally wanted to fetch all comments by default but that would have been a lot of potentially unused data
+ answerCommentIndex: null,
+ accountCanComment: null // Is this account allowed to comment on this discussion?
+ };
+
+ // Get DOM of discussion
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'GET',
+ url: url + '?l=en',
+ source: 'steamcommunity'
+ });
+
+
+ try {
+
+ /* --------------------- Preprocess output --------------------- */
+
+ // Load output into cheerio to make parsing easier
+ let $ = Cheerio.load(result.textBody);
+
+ // Get breadcrumbs once. Depending on the type of discussion, it either uses "forum" or "group" breadcrumbs
+ let breadcrumbs = $('.forum_breadcrumbs').children();
+
+ if (breadcrumbs.length == 0) breadcrumbs = $('.group_breadcrumbs').children();
+
+ // Steam redirects us to the forum page if the discussion does not exist which we can detect by missing breadcrumbs
+ if (!breadcrumbs[0]) {
+ reject(new Error('Discussion not found'));
+ return;
+ }
+
+ /* --------------------- Find and map values --------------------- */
+
+ // Determine type from URL as some checks will deviate, depending on the type
+ if (url.includes('steamcommunity.com/discussions/forum')) discussion.type = EDiscussionType.Forum;
+ if (/steamcommunity.com\/app\/.+\/discussions/g.test(url)) discussion.type = EDiscussionType.App;
+ if (/steamcommunity.com\/groups\/.+\/discussions/g.test(url)) discussion.type = EDiscussionType.Group;
+ if (/steamcommunity.com\/app\/.+\/eventcomments/g.test(url)) discussion.type = EDiscussionType.Eventcomments;
+
+
+ // Get appID from breadcrumbs if this discussion is associated to one
+ if (discussion.type == EDiscussionType.App) {
+ let appIdHref = breadcrumbs[0].attribs.href.split('/');
+
+ discussion.appID = appIdHref[appIdHref.length - 1];
+ }
+
+
+ // Get forumID from breadcrumbs - Ignore for type Eventcomments as it doesn't have multiple forums
+ if (discussion.type != EDiscussionType.Eventcomments) {
+ let forumIdHref;
+
+ if (discussion.type == EDiscussionType.Group) { // Groups have an extra breadcrumb so we need to shift by 2
+ forumIdHref = breadcrumbs[4].attribs.href.split('/');
+ } else {
+ forumIdHref = breadcrumbs[2].attribs.href.split('/');
+ }
+
+ discussion.forumID = forumIdHref[forumIdHref.length - 2];
+ }
+
+
+ // Get id, gidforum and topicOwner. The first is used in the URL itself, the other two only in post requests
+ let gids = $('.forum_paging > .forum_paging_controls').attr('id').split('_');
+
+ discussion.id = gids[4];
+ discussion.gidforum = gids[3];
+ discussion.topicOwner = gids[2];
+
+
+ // Find postedDate and convert to timestamp
+ let posted = $('.topicstats > .topicstats_label:contains("Date Posted:")').next().text();
+
+ discussion.postedDate = Helpers.decodeSteamTime(posted.trim());
+
+
+ // Find commentsAmount
+ discussion.commentsAmount = Number($('.topicstats > .topicstats_label:contains("Posts:")').next().text());
+
+
+ // Get discussion title & content
+ discussion.title = $('.forum_op > .topic').text().trim();
+ discussion.content = $('.forum_op > .content').text().trim();
+
+
+ // Find comment marked as answer
+ let hasAnswer = $('.commentthread_answer_bar');
+
+ if (hasAnswer.length != 0) {
+ let answerPermLink = hasAnswer.next().children('.forum_comment_permlink').text().trim();
+
+ // Convert comment id to number, remove hashtag and subtract by 1 to make it an index
+ discussion.answerCommentIndex = Number(answerPermLink.replace('#', '')) - 1;
+ }
+
+
+ // Check if this account is allowed to comment on this discussion
+ let cannotReplyReason = $('.topic_cannotreply_reason');
+
+ discussion.accountCanComment = cannotReplyReason.length == 0;
+
+
+ // Find author and convert to SteamID object - Ignore for type Eventcomments as they are posted by the "game", not by an Individual
+ if (discussion.type != EDiscussionType.Eventcomments) {
+ let authorLink = $('.authorline > .forum_op_author').attr('href');
+
+ Helpers.resolveVanityURL(authorLink, (err, data) => { // This request takes <1 sec
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ discussion.author = new SteamID(data.steamID);
+
+ // Resolve when ID was resolved as otherwise owner will always be null
+ resolve(new CSteamDiscussion(this, discussion));
+ });
+ } else {
+ resolve(new CSteamDiscussion(this, discussion));
+ }
+
+ } catch (err) {
+ reject(err);
+ }
+ });
+};
+
+
+/**
+ * Constructor - Creates a new Discussion object
+ * @class
+ * @param {SteamCommunity} community
+ * @param {{ id: string, type: EDiscussionType, appID: string, forumID: string, gidforum: string, topicOwner: string, author: SteamID, postedDate: Object, title: string, content: string, commentsAmount: number, answerCommentIndex: number, accountCanComment: boolean }} data
+ */
+function CSteamDiscussion(community, data) {
+ /**
+ * @type {SteamCommunity}
+ */
+ this._community = community;
+
+ // Clone all the data we received
+ Object.assign(this, data);
+}
+
+
+/**
+ * Scrapes a range of comments from this discussion
+ * @param {number} startIndex - Index (0 based) of the first comment to fetch
+ * @param {number} endIndex - Index (0 based) of the last comment to fetch
+ * @param {function} callback - First argument is null/Error, second is array containing the requested comments
+ */
+CSteamDiscussion.prototype.getComments = function(startIndex, endIndex, callback) {
+ this._community.getDiscussionComments(`https://steamcommunity.com/app/${this.appID}/discussions/${this.forumID}/${this.id}`, startIndex, endIndex, callback);
+};
+
+
+/**
+ * Posts a comment to this discussion's comment section
+ * @param {String} message - Content of the comment to post
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+CSteamDiscussion.prototype.comment = function(message, callback) {
+ this._community.postDiscussionComment(this.topicOwner, this.gidforum, this.id, message, callback);
+};
+
+
+/**
+ * Delete a comment from this discussion's comment section
+ * @param {String} gidcomment - ID of the comment to delete
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+CSteamDiscussion.prototype.deleteComment = function(gidcomment, callback) {
+ this._community.deleteDiscussionComment(this.topicOwner, this.gidforum, this.id, gidcomment, callback);
+};
+
+
+/**
+ * Subscribes to this discussion's comment section
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+CSteamDiscussion.prototype.subscribe = function(callback) {
+ this._community.subscribeDiscussionComments(this.topicOwner, this.gidforum, this.id, callback);
+};
+
+
+/**
+ * Unsubscribes from this discussion's comment section
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+CSteamDiscussion.prototype.unsubscribe = function(callback) {
+ this._community.unsubscribeDiscussionComments(this.topicOwner, this.gidforum, this.id, callback);
+};
diff --git a/components/discussions.js b/components/discussions.js
new file mode 100644
index 0000000..0263805
--- /dev/null
+++ b/components/discussions.js
@@ -0,0 +1,296 @@
+const Cheerio = require('cheerio');
+const StdLib = require('@doctormckay/stdlib');
+
+const SteamCommunity = require('../index.js');
+const Helpers = require('./helpers.js');
+
+
+/**
+ * Scrapes a range of comments from a Steam discussion
+ * @param {url} url - SteamCommunity url pointing to the discussion to fetch
+ * @param {number} startIndex - Index (0 based) of the first comment to fetch
+ * @param {number} endIndex - Index (0 based) of the last comment to fetch
+ * @param {function} callback - First argument is null/Error, second is array containing the requested comments
+ */
+SteamCommunity.prototype.getDiscussionComments = function(url, startIndex, endIndex, callback) {
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'GET',
+ url: url + '?l=en',
+ source: 'steamcommunity'
+ });
+
+
+ // Load output into cheerio to make parsing easier
+ let $ = Cheerio.load(result.textBody);
+
+ let paging = $('.forum_paging > .forum_paging_summary').children();
+
+ /**
+ * Stores every loaded page inside a Cheerio instance
+ * @type {{[key: number]: cheerio.Root}}
+ */
+ let pages = {
+ 0: $
+ };
+
+
+ // Determine amount of comments per page and total. Update endIndex if null to get all comments
+ let commentsPerPage = Number(paging[4].children[0].data);
+ let totalComments = Number(paging[5].children[0].data);
+
+ if (endIndex == null || endIndex > totalComments - 1) { // Make sure to check against null as the index 0 would cast to false
+ endIndex = totalComments - 1;
+ }
+
+
+ // Save all pages that need to be fetched in order to get the requested comments
+ let firstPage = Math.trunc(startIndex / commentsPerPage); // Index of the first page that needs to be fetched
+ let lastPage = Math.trunc(endIndex / commentsPerPage);
+ let promises = [];
+
+ for (let i = firstPage; i <= lastPage; i++) {
+ if (i == 0) continue; // First page is already in pages object
+
+ promises.push(new Promise((resolve) => {
+ setTimeout(async () => { // Delay fetching a bit to reduce the risk of Steam blocking us
+
+ let commentsPage = await this.httpRequest({
+ method: 'GET',
+ url: url + '?l=en&ctp=' + (i + 1),
+ source: 'steamcommunity'
+ });
+
+ try {
+ pages[i] = Cheerio.load(commentsPage.textBody);
+ resolve();
+ } catch (err) {
+ return reject('Failed to load comments page: ' + err);
+ }
+
+ }, 250 * i);
+ }));
+ }
+
+ await Promise.all(promises); // Wait for all pages to be fetched
+
+
+ // Fill comments with content of all comments
+ let comments = [];
+
+ for (let i = startIndex; i <= endIndex; i++) {
+ let $ = pages[Math.trunc(i / commentsPerPage)];
+
+ let thisComment = $(`.forum_comment_permlink:contains("#${i + 1}")`).parent();
+ let thisCommentID = thisComment.attr('id').replace('comment_', '');
+
+ // Note: '>' inside the cheerio selectors didn't work here
+ let authorContainer = thisComment.children('.commentthread_comment_content').children('.commentthread_comment_author').children('.commentthread_author_link');
+ let commentContainer = thisComment.children('.commentthread_comment_content').children(`#comment_content_${thisCommentID}`);
+
+
+ // Prepare comment text by finding all existing blockquotes, formatting them and adding them infront each other. Afterwards handle the text itself
+ let commentText = '';
+ let blockQuoteSelector = '.bb_blockquote';
+ let children = commentContainer.children(blockQuoteSelector);
+
+ for (let i = 0; i < 10; i++) { // I'm not sure how I could dynamically check the amount of nested blockquotes. 10 is prob already too much to stay readable
+ if (children.length > 0) {
+ let thisQuoteText = '';
+
+ thisQuoteText += children.children('.bb_quoteauthor').text() + '\n'; // Get quote header and add a proper newline
+
+ // Replace
's with newlines to get a proper output
+ let quoteWithNewlines = children.first().find('br').replaceWith('\n');
+
+ thisQuoteText += quoteWithNewlines.end().contents().filter(function() { return this.type === 'text'; }).text().trim(); // Get blockquote content without child content - https://stackoverflow.com/a/23956052
+ if (i > 0) thisQuoteText += '\n-------\n'; // Add spacer
+
+ commentText = thisQuoteText + commentText; // Concat quoteText to the start of commentText as the most nested quote is the first one inside the comment chain itself
+
+ // Go one level deeper
+ children = children.children(blockQuoteSelector);
+
+ } else {
+
+ commentText += '\n\n-------\n\n'; // Add spacer
+ break;
+ }
+ }
+
+ let quoteWithNewlines = commentContainer.first().find('br').replaceWith('\n'); // Replace
's with newlines to get a proper output
+
+ commentText += quoteWithNewlines.end().contents().filter(function() { return this.type === 'text'; }).text().trim(); // Add comment content without child content - https://stackoverflow.com/a/23956052
+
+
+ comments.push({
+ index: i,
+ commentId: thisCommentID,
+ commentLink: `${url}#c${thisCommentID}`,
+ authorLink: authorContainer.attr('href'), // I did not call 'resolveVanityURL()' here and convert to SteamID to reduce the amount of potentially unused Steam pings
+ postedDate: Helpers.decodeSteamTime(authorContainer.children('.commentthread_comment_timestamp').text().trim()),
+ content: commentText.trim()
+ });
+ }
+
+
+ // Resolve with our result
+ resolve(comments);
+
+ });
+};
+
+/**
+ * Posts a comment to a discussion
+ * @param {String} topicOwner - ID of the topic owner
+ * @param {String} gidforum - GID of the discussion's forum
+ * @param {String} discussionId - ID of the discussion
+ * @param {String} message - Content of the comment to post
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+SteamCommunity.prototype.postDiscussionComment = function(topicOwner, gidforum, discussionId, message, callback) {
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'POST',
+ url: `https://steamcommunity.com/comment/ForumTopic/post/${topicOwner}/${gidforum}/`,
+ form: {
+ comment: message,
+ count: 15,
+ sessionid: this.getSessionID(),
+ extended_data: '{"topic_permissions":{"can_view":1,"can_post":1,"can_reply":1}}',
+ feature2: discussionId,
+ json: 1
+ },
+ source: 'steamcommunity'
+ });
+
+ if (result.jsonBody.success) {
+ resolve(null);
+ } else {
+ reject(new Error(result.jsonBody.error));
+ }
+ });
+};
+
+/**
+ * Deletes a comment from a discussion
+ * @param {String} topicOwner - ID of the topic owner
+ * @param {String} gidforum - GID of the discussion's forum
+ * @param {String} discussionId - ID of the discussion
+ * @param {String} gidcomment - ID of the comment to delete
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+SteamCommunity.prototype.deleteDiscussionComment = function(topicOwner, gidforum, discussionId, gidcomment, callback) {
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'POST',
+ url: `https://steamcommunity.com/comment/ForumTopic/delete/${topicOwner}/${gidforum}/`,
+ form: {
+ gidcomment: gidcomment,
+ count: 15,
+ sessionid: this.getSessionID(),
+ extended_data: '{"topic_permissions":{"can_view":1,"can_post":1,"can_reply":1}}',
+ feature2: discussionId,
+ json: 1
+ },
+ source: 'steamcommunity'
+ });
+
+ if (result.jsonBody.success) {
+ resolve(null);
+ } else {
+ reject(new Error(result.jsonBody.error));
+ }
+ });
+};
+
+/**
+ * Subscribes to a discussion's comment section
+ * @param {String} topicOwner - ID of the topic owner
+ * @param {String} gidforum - GID of the discussion's forum
+ * @param {String} discussionId - ID of the discussion
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+SteamCommunity.prototype.subscribeDiscussionComments = function(topicOwner, gidforum, discussionId, callback) {
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'POST',
+ url: `https://steamcommunity.com/comment/ForumTopic/subscribe/${topicOwner}/${gidforum}/`,
+ form: {
+ count: 15,
+ sessionid: this.getSessionID(),
+ extended_data: '{"topic_permissions":{"can_view":1,"can_post":1,"can_reply":1}}',
+ feature2: discussionId,
+ json: 1
+ },
+ source: 'steamcommunity'
+ });
+
+ if (result.jsonBody.success && result.jsonBody.success != SteamCommunity.EResult.OK) {
+ reject(Helpers.eresultError(result.jsonBody.success));
+ return;
+ }
+
+ resolve(null);
+ });
+};
+
+/**
+ * Unsubscribes from a discussion's comment section
+ * @param {String} topicOwner - ID of the topic owner
+ * @param {String} gidforum - GID of the discussion's forum
+ * @param {String} discussionId - ID of the discussion
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+SteamCommunity.prototype.unsubscribeDiscussionComments = function(topicOwner, gidforum, discussionId, callback) {
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'POST',
+ url: `https://steamcommunity.com/comment/ForumTopic/unsubscribe/${topicOwner}/${gidforum}/`,
+ form: {
+ count: 15,
+ sessionid: this.getSessionID(),
+ extended_data: '{}', // Unsubscribing does not require any data here
+ feature2: discussionId,
+ json: 1
+ },
+ source: 'steamcommunity'
+ });
+
+ if (result.jsonBody.success && result.jsonBody.success != SteamCommunity.EResult.OK) {
+ reject(Helpers.eresultError(result.jsonBody.success));
+ return;
+ }
+
+ resolve(null);
+ });
+};
+
+/**
+ * Sets an amount of comments per page
+ * @param {String} value - 15, 30 or 50
+ * @param {function} callback - Takes only an Error object/null as the first argument
+ */
+SteamCommunity.prototype.setDiscussionCommentsPerPage = function(value, callback) {
+ if (!['15', '30', '50'].includes(value)) value = '50'; // Check for invalid setting
+
+ return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => {
+ let result = await this.httpRequest({
+ method: 'POST',
+ url: 'https://steamcommunity.com/forum/0/0/setpreference',
+ form: {
+ preference: 'topicrepliesperpage',
+ value: value,
+ sessionid: this.getSessionID(),
+ },
+ source: 'steamcommunity'
+ });
+
+ if (result.jsonBody.success && result.jsonBody.success != SteamCommunity.EResult.OK) {
+ reject(Helpers.eresultError(result.jsonBody.success));
+ return;
+ }
+
+ resolve(null);
+ });
+};
\ No newline at end of file
diff --git a/index.js b/index.js
index 0fc31ed..e8ce722 100644
--- a/index.js
+++ b/index.js
@@ -587,9 +587,11 @@ require('./components/inventoryhistory.js');
require('./components/webapi.js');
require('./components/twofactor.js');
require('./components/confirmations.js');
+require('./components/discussions.js');
require('./components/help.js');
require('./classes/CMarketItem.js');
require('./classes/CMarketSearchResult.js');
+require('./classes/CSteamDiscussion.js');
require('./classes/CSteamGroup.js');
require('./classes/CSteamSharedFile.js');
require('./classes/CSteamUser.js');
diff --git a/resources/EDiscussionType.js b/resources/EDiscussionType.js
new file mode 100644
index 0000000..30996bd
--- /dev/null
+++ b/resources/EDiscussionType.js
@@ -0,0 +1,15 @@
+/**
+ * @enum EDiscussionType
+ */
+module.exports = {
+ Forum: 0,
+ App: 1,
+ Group: 2,
+ Eventcomments: 3,
+
+ // Value-to-name mapping for convenience
+ 0: 'Forum',
+ 1: 'App',
+ 2: 'Group',
+ 3: 'Eventcomments'
+};