<commit_before>// WebChatty
// Copyright (C) 2016 <NAME>, <NAME>, <NAME>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
// Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
// OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

/// <reference path="../../../typings/tsd.d.ts" />
"use strict";

import * as util from "util";
import * as api from "../api/index";
import * as connectors from "../connectors/index";
import * as spec from "../spec/index";
import * as moment from "moment";
import { Dictionary } from "../collections/index";

export interface ShacknewsThreadConnectorConfig {
    pollingIntervalMsec: number;
}

export class ShacknewsThreadConnector {
    private _server: api.Server;
    private _common: connectors.ShacknewsCommon;
    private _pollTimer: ResettableTimer;
    private _lastModLogId: number = 0; // last chat_mod_log id seen
    private _lastPostId: number = 0; // last chat_posts id seen
    
    constructor(common: connectors.ShacknewsCommon, config: ShacknewsThreadConnectorConfig) {
        this._common = common;
        this._pollTimer = new ResettableTimer(config.pollingIntervalMsec, this.pollForChanges);
    }
    
    // Called by the server at startup to provide the connector with a reference to the server instance.
    public injectServer(server: api.Server): void {
        this._server = server;
    }
    
    // Called when the server is about to start listening for requests.
    public async start(): Promise<void> {
        const modLogRows: { id: number }[] = await this._common.query(`SELECT MAX(id) AS id FROM chat_mod_log`, {});
        if (modLogRows.length === 1) {
            this._lastModLogId = modLogRows[0].id;
        }

        const postRows: { id: number }[] = await this._common.query(`SELECT MAX(id) AS id FROM chat_posts`, {});
        if (postRows.length === 1) {
            this._lastPostId = postRows[0].id;
        }
        
        this._pollTimer.start();
    }
    
    // Called when the server is shutting down.
    public async stop(): Promise<void> {
        await this._pollTimer.stop();
    }
    
    // Gets the list of recently bumped threads, starting with the most recently bumped.  
    // Only non-nuked, non-expired threads are included.  Up to "maxThreads" of the most recent threads are returned.  
    // "expirationHours" is the number of hours to retain a thread in this list.
    public async getActiveThreadIds(maxThreads: number, expirationHours: number): Promise<number[]> {
        const rows: { id: number; }[] = await this._common.query(
            `SELECT op.id
            FROM chat_posts AS op
            INNER JOIN chat_posts AS replies ON op.id = replies.root_id
            WHERE
                op.id = op.root_id AND
                op.post_time > DATE_ADD(CURDATE(), INTERVAL :negHours HOUR) AND
                op.content_id != 99
            GROUP BY op.id
            ORDER BY MAX(replies.id) DESC`,
            { negHours: -expirationHours }
        );
        return rows.map(x => x.id);
    }
    
    // Gets all posts (including nuked posts) in all specified threads, in no particular order.  The IDs may be 
    // replies inside the thread, not necessarily the thread root.  If multiple post IDs in the same thread are 
    // specified, that thread's posts are returned only once.  If a post ID does not exist, then the thread is silently
    // omitted from the results.
    public async getThreads(postIds: number[]): Promise<spec.Post[]> {
        if (postIds.length === 0) {
            return [];
        }
        interface Row {
            id: number;
            threadId: number;
            parentId: number;
            author: string;
            category: number;
            date: Date;
            body: string;
        }
        const rows: Row[] = await this._common.query(
            `SELECT
                id, q.root_id AS threadId, parent_id AS parentId, u.username AS author,
                mod_type_id AS category, post_time AS date, body
            FROM chat_posts AS q
            INNER JOIN (
                SELECT DISTINCT p.root_id
                FROM chat_posts p
                WHERE p.id IN (${postIds.map((x,i) => `:arg${i}`).join(",")})
            ) AS a ON q.root_id = a.root_id
            INNER JOIN users u ON q.user_id = u.id
            WHERE content_id != 99`, 
            this.arrayToObject(postIds.map((x,i) => ({ index: i, value: x})), x => `:arg${x.index}`, x => x.value) 
        );
        return rows.map(x => <spec.Post>{
            id: x.id,
            threadId: x.threadId,
            parentId: x.parentId,
            author: x.author,
            category: this.parseCategory(x.category),
            date: x.date,
            body: x.body,
            lols: []
        });
    }
    
    // Resolves the new post ID if it worked.  parentId may be 0 to post a new thread.
    // The caller has already verified that the user is not banned.
    // May reject with ERR_POST_RATE_LIMIT, ERR_NUKED, ERR_INVALID_PARENT.
    // The thread connector must arrange for the NewPost event to be sent.  The post body sent in the event data must 
    // be in HTML (i.e. by calling spec.tagsToHtml(text)).
    public async postComment(credentials: spec.UserCredentials, parentId: number, text: string): Promise<number> {
        var contentId = 17, contentTypeId = 17;
        if (parentId > 0) {
            const rows: { contentId: number; contentTypeId: number; }[] = await this._common.query(
                `SELECT content_id AS contentId, content_type_id AS contentTypeId
                FROM chat_posts
                WHERE id = :parentId`,
                { parentId: parentId }
            );
            if (rows.length === 0) {
                return Promise.reject(spec.apiError("ERR_INVALID_PARENT", "Parent ID was not found."));
            }
            contentId = rows[0].contentId;
            contentTypeId = rows[0].contentTypeId;
        }
        
        // can't post to the mod board with this API
        if (contentId === 99) {
            return Promise.reject(spec.apiError("ERR_INVALID_PARENT", "Parent ID was not found."));
        }
        
        const http = await this._common.newUserSession(credentials);
        const response = await http.request("post", "https://www.shacknews.com/post_chatty.x", {
            "parent_id": parentId === 0 ? "" : parentId,
            "content_type_id": contentTypeId,
            "content_id": contentId,
            "page": "",
            "parent_url": "/chatty",
            "body": text
        });
        
        // the response is a bunch of HTML that includes a line like this:
        //      navigate_page_no_history( window, "/frame_chatty.x?mode=refresh&anchor=1&id=34615541");
        // we can parse the new post ID from there.
        const re = /\/frame_chatty\.x\?mode=refresh&anchor=1&id=([0-9]+)"/;
        const m = re.exec(response);
        if (m === null) {
            return Promise.reject("Unable to parse the new post ID from the Shacknews response.");
        } else {
            return parseInt(m[1]);
        }
        
        // trigger an immediate check for new posts and post changes.  this will send the NewPost event.
        await this._pollTimer.triggerNow();
    }
    
    // Resolves the newest post ID in the database, or 0 if there are no posts.  The newest post may be nuked.
    public async getNewestPostId(): Promise<number> {
        const rows: { id: number; }[] = await this._common.query(<commit_msg>fix(server): Fix unintended dead code.<commit_after>// WebChatty
// Copyright (C) 2016 <NAME>, <NAME>, <NAME>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
// Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
// OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

/// <reference path="../../../typings/tsd.d.ts" />
"use strict";

import * as util from "util";
import * as api from "../api/index";
import * as connectors from "../connectors/index";
import * as spec from "../spec/index";
import * as moment from "moment";
import { Dictionary } from "../collections/index";

export interface ShacknewsThreadConnectorConfig {
    pollingIntervalMsec: number;
}

export class ShacknewsThreadConnector {
    private _server: api.Server;
    private _common: connectors.ShacknewsCommon;
    private _pollTimer: ResettableTimer;
    private _lastModLogId: number = 0; // last chat_mod_log id seen
    private _lastPostId: number = 0; // last chat_posts id seen
    
    constructor(common: connectors.ShacknewsCommon, config: ShacknewsThreadConnectorConfig) {
        this._common = common;
        this._pollTimer = new ResettableTimer(config.pollingIntervalMsec, this.pollForChanges);
    }
    
    // Called by the server at startup to provide the connector with a reference to the server instance.
    public injectServer(server: api.Server): void {
        this._server = server;
    }
    
    // Called when the server is about to start listening for requests.
    public async start(): Promise<void> {
        const modLogRows: { id: number }[] = await this._common.query(`SELECT MAX(id) AS id FROM chat_mod_log`, {});
        if (modLogRows.length === 1) {
            this._lastModLogId = modLogRows[0].id;
        }

        const postRows: { id: number }[] = await this._common.query(`SELECT MAX(id) AS id FROM chat_posts`, {});
        if (postRows.length === 1) {
            this._lastPostId = postRows[0].id;
        }
        
        this._pollTimer.start();
    }
    
    // Called when the server is shutting down.
    public async stop(): Promise<void> {
        await this._pollTimer.stop();
    }
    
    // Gets the list of recently bumped threads, starting with the most recently bumped.  
    // Only non-nuked, non-expired threads are included.  Up to "maxThreads" of the most recent threads are returned.  
    // "expirationHours" is the number of hours to retain a thread in this list.
    public async getActiveThreadIds(maxThreads: number, expirationHours: number): Promise<number[]> {
        const rows: { id: number; }[] = await this._common.query(
            `SELECT op.id
            FROM chat_posts AS op
            INNER JOIN chat_posts AS replies ON op.id = replies.root_id
            WHERE
                op.id = op.root_id AND
                op.post_time > DATE_ADD(CURDATE(), INTERVAL :negHours HOUR) AND
                op.content_id != 99
            GROUP BY op.id
            ORDER BY MAX(replies.id) DESC`,
            { negHours: -expirationHours }
        );
        return rows.map(x => x.id);
    }
    
    // Gets all posts (including nuked posts) in all specified threads, in no particular order.  The IDs may be 
    // replies inside the thread, not necessarily the thread root.  If multiple post IDs in the same thread are 
    // specified, that thread's posts are returned only once.  If a post ID does not exist, then the thread is silently
    // omitted from the results.
    public async getThreads(postIds: number[]): Promise<spec.Post[]> {
        if (postIds.length === 0) {
            return [];
        }
        interface Row {
            id: number;
            threadId: number;
            parentId: number;
            author: string;
            category: number;
            date: Date;
            body: string;
        }
        const rows: Row[] = await this._common.query(
            `SELECT
                id, q.root_id AS threadId, parent_id AS parentId, u.username AS author,
                mod_type_id AS category, post_time AS date, body
            FROM chat_posts AS q
            INNER JOIN (
                SELECT DISTINCT p.root_id
                FROM chat_posts p
                WHERE p.id IN (${postIds.map((x,i) => `:arg${i}`).join(",")})
            ) AS a ON q.root_id = a.root_id
            INNER JOIN users u ON q.user_id = u.id
            WHERE content_id != 99`, 
            this.arrayToObject(postIds.map((x,i) => ({ index: i, value: x})), x => `:arg${x.index}`, x => x.value) 
        );
        return rows.map(x => <spec.Post>{
            id: x.id,
            threadId: x.threadId,
            parentId: x.parentId,
            author: x.author,
            category: this.parseCategory(x.category),
            date: x.date,
            body: x.body,
            lols: []
        });
    }
    
    // Resolves the new post ID if it worked.  parentId may be 0 to post a new thread.
    // The caller has already verified that the user is not banned.
    // May reject with ERR_POST_RATE_LIMIT, ERR_NUKED, ERR_INVALID_PARENT.
    // The thread connector must arrange for the NewPost event to be sent.  The post body sent in the event data must 
    // be in HTML (i.e. by calling spec.tagsToHtml(text)).
    public async postComment(credentials: spec.UserCredentials, parentId: number, text: string): Promise<number> {
        var contentId = 17, contentTypeId = 17;
        if (parentId > 0) {
            const rows: { contentId: number; contentTypeId: number; }[] = await this._common.query(
                `SELECT content_id AS contentId, content_type_id AS contentTypeId
                FROM chat_posts
                WHERE id = :parentId`,
                { parentId: parentId }
            );
            if (rows.length === 0) {
                return Promise.reject(spec.apiError("ERR_INVALID_PARENT", "Parent ID was not found."));
            }
            contentId = rows[0].contentId;
            contentTypeId = rows[0].contentTypeId;
        }
        
        // can't post to the mod board with this API
        if (contentId === 99) {
            return Promise.reject(spec.apiError("ERR_INVALID_PARENT", "Parent ID was not found."));
        }
        
        const http = await this._common.newUserSession(credentials);
        const response = await http.request("post", "https://www.shacknews.com/post_chatty.x", {
            "parent_id": parentId === 0 ? "" : parentId,
            "content_type_id": contentTypeId,
            "content_id": contentId,
            "page": "",
            "parent_url": "/chatty",
            "body": text
        });
        
        // the response is a bunch of HTML that includes a line like this:
        //      navigate_page_no_history( window, "/frame_chatty.x?mode=refresh&anchor=1&id=34615541");
        // we can parse the new post ID from there.
        const re = /\/frame_chatty\.x\?mode=refresh&anchor=1&id=([0-9]+)"/;
        const m = re.exec(response);
        if (m === null) {
            return Promise.reject("Unable to parse the new post ID from the Shacknews response.");
        }
        
        // trigger an immediate check for new posts and post changes.  this will send the NewPost event.
        await this._pollTimer.triggerNow();
        return parseInt(m[1]);
    }
    
    // Resolves the newest post ID in the database, or 0 if there are no posts.  The newest post may be nuked.
    public async getNewestPostId(): Promise<number> {
        const rows: { id: number; }[] = await this._common.query(
{'fraction_non_alphanumeric': 0.10038013779995249, 'fraction_numerical': 0.004870515561891185, 'mean_word_length': 2.2310497025522933, 'pattern_counts': {'":': 12, '<': 25, '<?xml version=': 0, '>': 41, 'https://': 2, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '6809920', 'n_tokens_mistral': 4755, 'n_tokens_neox': 4577, 'n_words': 1690}