12
\$\begingroup\$

Originally, this script stemmed from a small script that watched stars and who made them, but a few days after launching, SE patched it so the people behind the stars aren't sent.

Regardless, BigBrother, named after the character from 1984, had other functions, like monitoring obscene or unsightly chat messages and monitoring entering and exiting the room.

I was in a heavy rush while coding this, so it's a lot more spaghetti than I expected.

There's a few things you need to change manually to run this:

  • Username, so it can avoid reflagging obscene posts it shares
  • Chatroom id, on line 162. Currently set to The Sandbox

Here's the code on GitHub too.

If you want to run this code, comment out the lines relating to calling emit and it should run with posting messages in console only.

// ==UserScript==
// @name         BigBrother
// @namespace    github.com/The-Quill
// @version      0.1
// @description  A chatroom watcher
// @author       Quill
// @match        http://chat.stackexchange.com/rooms/1/sandbox
// @grant        none
// ==/UserScript==

(function (global) {
    "use strict";

    //username
    var USERNAME = 'BigBrother';
    var WELCOME_TEXT = '*initialising.*';
    //magic numbers
    var EVENT_TYPES = {
          MessagePosted: 1
        , MessageEdited: 2
        , UserEntered: 3
        , UserLeft: 4
        , RoomNameChanged: 5
        , MessageStarred: 6
        , DebugMessage: 7
        , UserMentioned: 8
        , MessageFlagged: 9
        , MessageDeleted: 10
        , FileAdded: 11
        , ModeratorFlag: 12
        , UserSettingsChanged: 13
        , GlobalNotification: 14
        , AccessLevelChanged: 15
        , UserNotification: 16
        , Invitation: 17
        , MessageReply: 18
        , MessageMovedOut: 19
        , MessageMovedIn: 20
        , TimeBreak: 21
        , FeedTicker: 22
        , UserSuspended: 29
        , UserMerged: 30
        // Custom Event Types
        , Notable: 'notable'
        , Welcome: 'welcome'
    };
    // bad or notable words
    var KEY_WORDS = {
        'off-topic': {
            regex: /off[\s|-]*topic/mi,
            value: .4
        },
        'troll':  {
            value: .5
        },
        'shit':  {
            regex: /s[\-\*hi]*t/mi,
            value: 1
        },
        'crap':  {
            regex: /c[\-\*ra]*p/mi,
            value: 1
        },
        'fuck':  {
            regex: /f[\-\*uc]*k/mi,
            value: 1
        }
    };
    function convertToMarkdownLink(value, href){
        return '[' + value + '](' + href + ')';
    }
    // storage 
    var key = 'sechatmonitor_data';

    if (!String.prototype.startsWith) {
        String.prototype.startsWith = function(searchString, position) {
            position = position || 0;
            return this.indexOf(searchString, position) === position;
        };
    }
    var storage = {
        data: {
            star: [],
            messages: [],
            kick: [],
            notable: [],
            users: {}
        },
        get: function (name) {
            return storage.data[name];
        },
        clear: function (name) {
            storage.data[name] = [];
        },
        add: function (name, value) {
            if (storage.contains(name, value)) {
                return;
            }
            if (storage.data[name]) {
                storage.data[name].push(value);
            } else {
                storage.data[name] = [value];
            }
        },
        modify_stars: function(name, room, value){
            if (!storage.data.users){
                storage.add('users', {});
            }
            if (!(storage.contains('users', name))){
                storage.data.users[name] = {};
            }
            if(!(room in storage.data.users[name])){
                storage.data.users[name][room] = {stars: 20, starred_posts: []};
            }
            storage.data.users[name][room].stars--;
            storage.data.users[name][room].starred_posts.push(value);
        },
        remove: function (name, value) {
            if (storage.contains(name, value)) {
                return;
            }
            if (storage.data[name]) {
                storage.data[name].remove(value);
            } else {
                storage.data[name] = [];
            }
        },
        save: function () {
            global.localStorage[key] = JSON.stringify(storage.data);
        },
        contains: function(name, value) {
            if (!storage.data[name]){ return false };

            for (var i = 0; i < storage.data[name].length; i++) {
                if (JSON.stringify(storage.data[name][i]) === JSON.stringify(value)) {
                    return true;
                }
            }
            return false;
        },
        load: function () {
            if (!global.localStorage[key]) {
                return;
            }
            storage.data = JSON.parse(global.localStorage[key]);
        }
    };
    storage.load();

    // data collection

    var socket, roomid, url, report, noisy, kickRe;

    report = true;
    noisy = true;
    roomid = Number(/\d+/.exec(location)[0]);
    kickRe = /^priv/;
    connect();

    function connect() {
        $.post('/ws-auth', fkey({
            roomid: 1
        })).done(function (data) {
            url = data.url;
            if (report) console.log('Connected');
            poll();
        });
    }

    function poll() {
        socket = new WebSocket(url + '?l=' + Date.now());
        socket.onmessage = ondata;
        socket.onclose = onclose;
    }

    function ondata(data) {
        var frame = JSON.parse(data.data);
        for (var room in frame) {
            if ('e' in frame[room]) {
                processEvent(frame[room].e[0]);
            }
        }
    }

    function onclose() {
        socket.close();
        socket = null;
        setTimeout(poll, 1000 * 10);
    }

    function processMessage(body){
        var sum = 0;
        for (var i in KEY_WORDS){
            var matchedContent = 0;
            if ('regex' in KEY_WORDS[i]){
                matchedContent = (body.match(KEY_WORDS[i].regex) == null ? 0 : body.match(KEY_WORDS[i].regex).length);
            } else {
                matchedContent = (body.split(' ' + i + ' ').length === 1 ? 0 : body.split(i).length / 2)
            }
            if (matchedContent != 0){
                matchedContent > 0 ? console.log([matchedContent, 'instances of', i].join(' ')) : '';
                sum += matchedContent * KEY_WORDS[i].value;
            }
        }
        return sum >= 1 ? sum : false;
    }
    function processStars(evt){
        console.log(evt);
        storage.modify_stars(evt.user_name, evt.room_name, {post_id: evt.post_id});
    }

    function processEvent(evt) {
        //console.log(evt);
        switch (evt.event_type) {
            case EVENT_TYPES.MessagePosted:
                if (USERNAME == evt.user_name){ return; }
                var sum = processMessage(evt.content);
                if (sum >= 1){
                    storage.add('notable', evt);
                    emit({event_type: 'notable', sum: sum, evt: evt});
                }
                break;
            case EVENT_TYPES.MessageStarred:
                processStars(evt);
                storage.add('star', evt);
                if (report){ console.log('star registered in', evt.room_name); }
                break;
        }
        emit(evt);
    }

    function emit(evt) {
        var bodyText = '',
            send     = false;
        //console.log(evt);
        var afterEffects = '';
        switch(evt.event_type){
            case EVENT_TYPES.Welcome:
                bodyText = WELCOME_TEXT;
                send = true;
                break;
            case EVENT_TYPES.Notable:
                bodyText = ['Notable post detected in', evt.evt.room_name, 'by', evt.evt.user_name + ';', 'Notariety level:', evt.sum].join(' ');
                afterEffects = 'http://chat.stackexchange.com/transcript/message/' + evt.evt.message_id;
                send = true;
                break;
            case EVENT_TYPES.UserEntered:
                bodyText = [
                      convertToMarkdownLink(evt.user_name, 'http://chat.stackexchange.com/users/' + evt.user_id)
                    , 'joined'
                    , convertToMarkdownLink(evt.room_name, 'http://chat.stackexchange.com/rooms/' + evt.room_id)  + '.'
                    , roomid != evt.room_id ? '' : 
                        [
                              'Welcome'
                            , '@' + evt.user_name.replace(' ', '') + '.'
                        ].join(', ')
                    ].join(' ');
                send = true;
                break;
            case EVENT_TYPES.UserLeft:
                bodyText = [
                      convertToMarkdownLink(evt.user_name, 'http://chat.stackexchange.com/users/' + evt.user_id)
                    , 'left'
                    , convertToMarkdownLink(evt.room_name, 'http://chat.stackexchange.com/rooms/' + evt.room_id)
                    ].join(' ');
                send = true;
                break;
            case EVENT_TYPES.MessageStarred:
                //console.log(evt);
                bodyText = [
                      evt.user_name
                    , ('message_stars' in evt ? '' : 'un') + 'starred'
                    , convertToMarkdownLink('this message', 'http://chat.stackexchange.com/transcript/message/' + evt.message_id)
                    , 'in'
                    , convertToMarkdownLink(evt.room_name, 'http://chat.stackexchange.com/rooms/' + evt.room_id)
                    ].join(' ');
                send = true;
                break;
            default:
                return;
                break;
        }
        if (!send){ return; }
        var d = new Date(); 
        var time = [d.getHours(), d.getMinutes(), d.getSeconds()].join(':');
        bodyText = [time, 'BB>', bodyText].join(' ');
        console.log(bodyText);
        storage.add('messages', bodyText);
        if (afterEffects != ''){
            storage.add('messages', afterEffects);
        }
        storage.save();
    }

    function messagePool (){
        setInterval(function(){
            var messages = storage.get('messages');
            if (messages.length > 0){
                sendMessageToAPI(messages[messages.length - 1]);
            }
        }, 10000);
    }

    function sendMessageToAPI(bodyText){
        return $.ajax({
            type: 'POST',
            url: ['/chats/', roomid, '/messages/new'].join(''),
            data: {
                fkey: fkey().fkey,
                text: bodyText
            }
        }).done(function(){
            storage.remove('messages', bodyText);
        });
    }

    function createTaskPool() {
        return setInterval(function() {
            if(tasks.length > 0) {
            // dequeue the message
            var lastTaskIndex = tasks.length - 1;
            var task = tasks[lastMessageIndex];
            tasks.splice(0, lastTaskIndex);
            executeTask(task);
            }
        });
    }

    function executeTask(task) {
        return $.ajax({
            type: 'POST',
            url: ['/chats/', roomid, '/messages/new'].join(''),
            data: {
                fkey: fkey().fkey,
                text: task.message
            }
        }).done(task.callback);
    }

    function enqueueMessage(message, callback) {
        var task = {
            message: message,
            callback: callback || function() {}
        };

        tasks.push(task);
    }

    global.reporter = {
        welcome: function(){
            emit({event_type: 'welcome'});
        },
        toggle: function(what, hrm) {
            if(!what) return 'you must toggle something';
            switch(what) {
                case 'noisy':
                    noisy = typeof hrm === 'undefined' ? !noisy : !!hrm;
                    return 'noisy is ' + noisy ? 'enabled' : 'disabled';
                break;
                case 'report': 
                    report = typeof hrm === 'undefined' ? !report : !!hrm;
                    return 'report is ' + report ? 'enabled' : 'disabled';
                break
                default: 
                    return 'you cannot toggle that';
                break
            }
        },
        get: function(what) {
            if(!what) return 'you must get something';
            switch(what) {
                case 'kicks':
                case 'kick':
                    return storage.get('kick');
                break;
                case 'stars': 
                case 'star':
                    return storage.get('star');
                break
                case 'users':
                case 'user':
                    return storage.get('rooms');
                break;
                default: 
                    return 'there is no storage for `' + what + '`';
                break
            }
        },
        clear: function(what) {
            if(!what){ return 'you must clear something' };
            switch(what) {
                case 'kicks':
                case 'kick':
                    return storage.clear('kick');
                break;
                case 'stars': 
                case 'star':
                    return storage.clear('star');
                break;
                case 'users':
                    return storage.clear('rooms');
                break;
                default: 
                    return 'there is no storage for `' + what + '`';
                break;
            }
            storage.save();
        }
    };
    messagePool();
}(window));reporter.welcome();
\$\endgroup\$
7
  • \$\begingroup\$ Are all the magic numbers in EVENT_TYPE actually meaningful? Just curious but it must have taken you a mighty long time to figure out each one! \$\endgroup\$
    – Phrancis
    Dec 25, 2015 at 8:27
  • \$\begingroup\$ They're meaningful in the sense that they could be used in future updates, and I can't remember how I got them, but the list of them was given to me in a chat message. \$\endgroup\$
    – Quill
    Dec 25, 2015 at 8:29
  • \$\begingroup\$ Oh nice. I would have figured SE would put those in a database or something instead of having +/- 20 magic numbers with all their own meanings \$\endgroup\$
    – Phrancis
    Dec 25, 2015 at 8:35
  • \$\begingroup\$ @Quill-HATMANIAC Didn't those event types exist in some other repository that you took some inspiration from? \$\endgroup\$ Dec 25, 2015 at 10:31
  • 1
    \$\begingroup\$ There is a MSE Post containing a compilation of the EVENT_TYPEs and their respective meaning \$\endgroup\$
    – Vogel612
    Dec 25, 2015 at 10:38

2 Answers 2

8
+100
\$\begingroup\$

Bug(s?) in createTaskPool

This function makes a reference to lastMessageIndex, but I cannot find that anywhere in the code. Perhaps it was meant to be lastTaskIndex instead?

function createTaskPool() {
    return setInterval(function() {
        if(tasks.length > 0) {
        // dequeue the message
        var lastTaskIndex = tasks.length - 1;
        var task = tasks[lastMessageIndex];
        tasks.splice(0, lastTaskIndex);
        executeTask(task);
        }
    });
}

Also the body of the if statement should be indented.

But even more importantly, I'm quite confused by this function. Essentially it takes the last element from the array, then deletes all elements in front of the last element, then execute the task. Didn't you mean to delete the last element and keep everything else? Writing the splice like this:

tasks.splice(lastTaskIndex);

Conditional logging

It seems the purpose of report is to decide to print messages with console.log or not. Instead of having many if (report) ... conditions here and there, it would be better to create a wrapper function to perform that check, for example:

function report(msg) {
    if (!settings.report) return;
    console.log(msg);
}

Repeated processing

In this code:

matchedContent = (body.match(KEY_WORDS[i].regex) == null ? 0 : body.match(KEY_WORDS[i].regex).length);

If the regex matched, then it will be performed again. It would be better to execute body.match(KEY_WORDS[i].regex) once and cache its result.

Confusing logic in processMessage

In processMessage, the scoring logic is strangely asymmetric for regex and plain strings:

if ('regex' in KEY_WORDS[i]){
    matchedContent = (body.match(KEY_WORDS[i].regex) == null ? 0 : body.match(KEY_WORDS[i].regex).length);
} else {
    matchedContent = (body.split(' ' + i + ' ').length === 1 ? 0 : body.split(i).length / 2)
}

When using regex, and there is a match, since all your patterns use /mi flags, it seems to me that the value of matchedContent will be always 1.

By contrast, when using plain strings, the value will be roughly the number of occurrences / 2 + 0.5. It's really hard to see the logic there. Also note that the logic is case sensitive in this case, and that seems undesirable.

To remedy this, I have two suggestions:

  1. Change the regex flags to /gmi. With the /g flag, the value here becomes the number of occurrences instead of always 1.

    • Btw all the current regexes use /mi. If you will always use the same flags, then you might want to not specify the flags at all in the KEY_WORDS object, but add /gmi automatically always.
  2. Convert plain strings to regex by wrapping between \b (word boundary), and add the /gmi flag too. This makes them case insensitive, like the regexes. After this change, you can continue processing like the regexes, so the scoring will be automatically consistent, as there will be one ultimate scoring system for both types (regex, plain strings)

Avoid returning two kinds of types

processMessage returns two kinds of types, numeric or boolean:

return sum >= 1 ? sum : false;

I suggest to stick with one kind of return type, numeric in this case.

Avoid pointless statements

This is ugly:

matchedContent > 0 ? console.log([matchedContent, 'instances of', i].join(' ')) : '';

When matchedContent <= 0 this will execute the pointless statement '';. It would be better to rewrite this as an if statement.

if (matchedContent > 0) console.log([matchedContent, 'instances of', i].join(' '));
\$\endgroup\$
4
  • \$\begingroup\$ It's funny you mention it because the startsWith implementation is from MDN \$\endgroup\$
    – Quill
    Jan 6, 2016 at 7:34
  • 1
    \$\begingroup\$ I guess what MDN wants to avoid is to create a new String object, which is what substr will do. Technically, I'd say the most efficient and most memory-saving way is to make a for loop to check char by char, but then there's that saying about microoptimizations being the root of all of something... ;) \$\endgroup\$ Jan 6, 2016 at 20:30
  • \$\begingroup\$ @Quill if that's what MDN recommends... fine, I dropped that point. \$\endgroup\$
    – janos
    Jan 6, 2016 at 21:39
  • \$\begingroup\$ @janos MDN aren't a perfect authority, feel free to keep your point :-) \$\endgroup\$
    – Quill
    Jan 6, 2016 at 21:41
7
\$\begingroup\$

A few thoughts, nothing major (ordered from macroscopic to microscopic) ...

  • Overall pattern is very close to the module pattern. Could go the extra mile and write window.BIGBROTHER = (function(){...}(window)) and still expose only the reporter object? It would be a very subtle change.
  • Code is already well organised into several functional areas but you could probably go further and reduce the current ~27 members in the IIFE's namespace. For example :
    • The code in the socket/roomid/url/connect()/poll() area looks like it could be encapsulated in an object.
    • Behavioural settings report and noisy could be made into properties of a settings object (with its own toggle method?).
  • The setInterval established in messagePool() cannot be cleared. Maybe a reporter.stop() method could be offered for completeness?
  • To be consistent with other inner namespaces, should storage be upper case STORAGE?
  • Not necessarily wrong but seems a little odd that :
    • Not all console.log()s are dependent on report.
    • Some functions have storage.add() but no storage.save().
  • In emit(), the send boolean seems to be redundant. Does the return in the switch's default not fulfil the same function as if (!send){ return; }?
  • typeof whatever === 'undefined' is unnecessary these days; whatever === undefined will suffice.
\$\endgroup\$
2
  • \$\begingroup\$ As for the send boolean, the script is designed to print to console as well as emit to chat, so setting the send boolean to false effectively cancels the sending to chat. \$\endgroup\$
    – Quill
    Dec 27, 2015 at 0:54
  • \$\begingroup\$ @Quill-HATMANIAC, I understand your intent but, unless I am misreading the code, emit() will do both or neither. There's no independent control over emitting and console.logging. \$\endgroup\$ Dec 28, 2015 at 1:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.