Source: socialMediaActivity.js

/**
 * This module provides utility functions for tracking social media posts.
 *
 * @module socialMediaActivity
 */

import * as debugging from "./debugging.js";
import * as messaging from "./messaging.js";
import * as permissions from "./permissions.js";
import * as timing from "./timing.js";
import facebookContentScript from "include:./content-scripts/socialMediaActivity.facebook.content.js";
import twitterContentScript from "include:./content-scripts/socialMediaActivity.twitter.content.js";

permissions.check({
    module: "webScience.socialMediaActivity",
    requiredPermissions: [ "webRequest" ],
    requiredOrigins: [
        "*://*.facebook.com/*",
        "*://*.twitter.com/*",
        "*://*.reddit.com/*"
    ],
    suggestedPermissions: [ "unlimitedStorage" ]
});

/**
 * @constant {debugging.debuggingLogger}
 * @private
 */
const debugLog = debugging.getDebuggingLog("socialMediaActivity");

let privateWindows = false;

let tweetContentSetUp = false;
let twitter_x_csrf_token = "";
let twitter_authorization = "";
let twitter_tabid = "";

let fbPostContentSetUp = false;
let facebookTabId = -1;

const processedRequestIds = {};

/**
 * Configure listeners to run in private windows.
 */
export function enablePrivateWindows() {
    privateWindows = true;
}

/** Unregister old handlers for an event, and register a new one, if necessary.
 * Unregistering is only necessary when there's already a nonblocking handler registered
 * and we want to convert it to a blocking handler.
 * @param platform - which social media platform the event is for
 * @param eventType - which type of event we're registering
 * @param blockingType - whether the handler should be blocking or not
 * @param callback - the client function to call when the event occurs
 * @private
 */
function registerPlatformListener(platform, eventType, blockingType, callback) {
    debugLog("Registering listener for " + platform + eventType);
    const blocking = blockingType == "blocking";
    const handler = platformHandlers[platform][eventType];

    if (handler.registeredListener == null ||
        (blockingType == "blocking" && handler.registeredBlockingType != "blocking")) {

        // if there is a nonblocking listener registered, we must be blocking (otherwise this code wouldn't run)
        // and if we're adding a blocking listener, we want to get rid of the nonblocking one
        if (handler.registeredListener != null && handler.registeredBlockingType == "nonblocking") {
            browser.webRequest[handler.stage].removeListener(handler.registeredListener);
        }
        const stage = handler.stage;
        const urls = handler.urls;
        handler.registeredListener = ((requestDetails) => {
            return handleGenericEvent({requestDetails: requestDetails, platform: platform,
                                eventType: eventType, blockingType: blockingType});
        });
        handler.registeredBlockingType = blockingType;
        browser.webRequest[stage].addListener(handler.registeredListener,
        {
            urls: urls,
            incognito: (privateWindows ? null : false)
        },
            blocking ? ["requestBody", blockingType] : ["requestBody"]);
    }
    clientCallbacks[platform][eventType][blockingType].push(callback);
}

/**
 * Register a callback for specific Twitter events. Supported events are "tweet" (includes
 * tweet variants such as replies), "retweet", and "favorite".
 * @param callback - the function to call when the event happens
 * @param [String] events - array of events to be tracked
 * @param blocking - whether the listener should be blocking. Allows canceling the event.
 */
export function registerTwitterActivityTracker(
    callback,
    events,
    blocking = false) {
    if (events.includes("tweet") || events.includes("<all_events>")) {
        registerPlatformListener("twitter", "tweet", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("retweet") || events.includes("<all_events>")) {
        registerPlatformListener("twitter", "retweet", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("favorite") || events.includes("<all_events>")) {
        registerPlatformListener("twitter", "favorite", blocking ? "blocking" : "nonblocking", callback);
    }
    tweetContentInit();
}

/**
 * Register a callback for specific Facebook events. Supported events are "post", "reshare",
 * "react" (includes like, love, etc), and "comment".
 * @param callback - the function to call when the event happens
 * @param [String] events - array of events to be tracked
 * @param blocking - whether the listener should be blocking. Allows canceling the event.
 */
export function registerFacebookActivityTracker(
    callback,
    events,
    blocking = false ){
    if (events.includes("post") || events.includes("<all_events>")) {
        registerPlatformListener("facebook", "post", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("reshare") || events.includes("<all_events>")) {
        registerPlatformListener("facebook", "reshare", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("react") || events.includes("<all_events>")) {
        registerPlatformListener("facebook", "react", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("comment") || events.includes("<all_events>")) {
        registerPlatformListener("facebook", "comment", blocking ? "blocking" : "nonblocking", callback);
    }
    fbPostContentInit();
}

/**
 * Register a callback for specific Reddit events. Supported events are "post", "comment",
 * "postVote", and "commentVote".
 * @param callback - the function to call when the event happens
 * @param [String] events - array of events to be tracked
 * @param blocking - whether the listener should be blocking. Allows canceling the event.
 */
export function registerRedditActivityTracker(
    callback,
    events,
    blocking = false) {
    if (events.includes("post") || events.includes("<all_events>")) {
        registerPlatformListener("reddit", "post", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("comment") || events.includes("<all_events>")) {
        registerPlatformListener("reddit", "comment", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("postVote") || events.includes("<all_events>")) {
        registerPlatformListener("reddit", "postVote", blocking ? "blocking" : "nonblocking", callback);
    }
    if (events.includes("commentVote") || events.includes("<all_events>")) {
        registerPlatformListener("reddit", "commentVote", blocking ? "blocking" : "nonblocking", callback);
    }
}

/**
 * Upon receiving any event, validate that it is a valid instance of the tracked action,
 * call parsers to extract relevant information, and call a blocking callback if it exists.
 * If the blocking callback cancels the event by returning an object containing a "cancel"
 * property, cancel the request. Otherwise, let the request continue. If there is not a
 * blocking listener or it lets the event continue, call the nonblocking listeners.
 * @param requestDetails - the raw request event from WebRequests
 * @param platform - which social media platform this event is from
 * @param eventType - which event this request should be
 * @param blockingType - whether a blocking listener should run
 * @private
 */
async function handleGenericEvent({requestDetails = null,
                             platform = null, eventType = null,
                             blockingType = null}) {
    const handler = platformHandlers[platform][eventType];
    const eventTime = timing.now();
    let verified = null;
    for (const verifier of handler.verifiers) {
        verified = await verifier({requestDetails: requestDetails, platform: platform,
            eventType: eventType, blockingType: blockingType,
            eventTime: eventTime});
        if (!verified) {
            return {};
        }
    }
    if (platform == "facebook") {
        facebookTabId = requestDetails.tabId;
    }
    let details = {};
    for (const extractor of handler.extractors) {
        details = await extractor({requestDetails: requestDetails, details: details,
            verified: verified, platform: platform, eventType: eventType,
            blockingType: blockingType, eventTime: eventTime});
        if (!details) {
            return {};
        }
    }
    let blockingResult;
    if (blockingType == "blocking") {
        blockingResult = await clientCallbacks[platform][eventType][blockingType][0](details);
        if (blockingResult && "cancel" in blockingResult) {
            return blockingResult;
        }
    }
    for (const userListener of clientCallbacks[platform][eventType]["nonblocking"]) {
        userListener(details);
    }
    for (const completer of handler.completers) {
        completer({requestDetails: requestDetails, verified: verified, details: details,
            platform: platform, eventType: eventType, blockingType: blockingType});
    }
}

/**
 * A generic verifier that makes sure a request is a POST.
 * @param requestDetails - the raw request
 * @private
 */
function verifyPostReq({requestDetails = null}) {
    if (!requestDetails) return null;
    if (!requestDetails.method == "POST") return null;
    return {};
}

/**
 * A generic verifier that makes sure the formData field is present.
 * @param requestDetails - the raw request
 * @private
 */
function verifyReadableFormData({requestDetails = null}) {
    if (!requestDetails.requestBody) return null;
    if (!requestDetails.requestBody.formData) return null;
    return {};
}

/**
 * Sometimes a redir gets issued (for the same url) and we see the event twice,
 * resulting in double-counting events. Check whether we've seen this requestId
 * already, cancel if so, and record the view if not.
 * Note: if multiple events listen to the same URL and distinguish events by
 * request contents, this verifier must be the LAST in the list
 * @param requestDetails - the raw request
 * @private
 */
function verifyNewRequest({requestDetails = null}) {
    if (!requestDetails.requestId) return null;
    if (processedRequestIds[requestDetails.requestId]) {
        return null;
    }
    processedRequestIds[requestDetails.requestId] = true;
    return {};
}

/**
 * Stores the callback functions the client has registered.
 * @private
 */
const clientCallbacks = {
    twitter: {
        tweet: {blocking: [], nonblocking: []},
        retweet: {blocking: [], nonblocking: []},
        favorite: {blocking: [], nonblocking: []},
    },
    facebook: {
        post: {blocking: [], nonblocking: []},
        react: {blocking: [], nonblocking: []},
        reshare: {blocking: [], nonblocking: []},
        comment: {blocking: [], nonblocking: []},
    },
    reddit: {
        post: {blocking: [], nonblocking: []},
        comment: {blocking: [], nonblocking: []},
        postVote: {blocking: [], nonblocking: []},
        commentVote: {blocking: [], nonblocking: []}
    }
}

/**
 * Holds the configuration for each type of handler.
 * @private
 */
const platformHandlers = {
    twitter: {
        tweet: null, retweet: null, favorite: null
    },
    facebook: {
        post: null, comment: null, react: null, reshare: null
    },
    reddit: {
        post: null, comment: null, postVote: null, commentVote: null
    }
}

platformHandlers.twitter.tweet = {
    stage: "onBeforeRequest",
    urls: ["https://twitter.com/intent/tweet", "https://api.twitter.com/*/statuses/update.json", "https://twitter.com/i/api/*/statuses/update.json"],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyTwitterTweet],
    extractors: [extractTwitterTweet],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.twitter.retweet = {
    stage: "onBeforeRequest",
    urls: ["https://api.twitter.com/*/statuses/retweet.json", "https://twitter.com/i/api/*/statuses/retweet.json"],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyTwitterRetweet],
    extractors: [extractTwitterRetweet],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.twitter.favorite = {
    stage: "onBeforeRequest",
    urls: ["https://api.twitter.com/*/favorites/create.json",
           "https://twitter.com/i/api/*/favorites/create.json"],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyTwitterFavorite],
    extractors: [extractTwitterFavorite],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};

platformHandlers.facebook.post = {
    stage: "onBeforeRequest",
    urls: ["https://www.facebook.com/webgraphql/mutation/?doc_id=*", // Old FB
           "https://www.facebook.com/api/graphql/" // New FB
          ],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyFacebookPost, verifyNewRequest],
    extractors: [extractFacebookPost],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.facebook.react = {
    stage: "onBeforeRequest",
    urls: ["https://www.facebook.com/api/graphql/"],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyFacebookReact],
    extractors: [extractFacebookReact],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.facebook.comment = {
    stage: "onBeforeRequest",
    urls: ["https://www.facebook.com/api/graphql/"],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyFacebookComment],
    extractors: [extractFacebookComment],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.facebook.reshare = {
    stage: "onBeforeRequest",
    urls: ["https://www.facebook.com/share/dialog/submit/*", // Old FB
           "https://www.facebook.com/api/graphql/" // New FB
          ],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyFacebookReshare, verifyNewRequest],
    extractors: [extractFacebookReshare],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};

platformHandlers.reddit.post = {
    stage: "onBeforeRequest",
    urls: [ "https://oauth.reddit.com/api/submit*" ],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyRedditPost],
    extractors: [extractRedditPost],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.reddit.comment = {
    stage: "onBeforeRequest",
    urls: [ "https://oauth.reddit.com/api/comment*" ],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyRedditComment],
    extractors: [extractRedditComment],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.reddit.postVote = {
    stage: "onBeforeRequest",
    urls: [ "https://oauth.reddit.com/api/vote*" ],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyRedditPostVote],
    extractors: [extractRedditPostVote],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};
platformHandlers.reddit.commentVote = {
    stage: "onBeforeRequest",
    urls: [ "https://oauth.reddit.com/api/vote*" ],
    verifiers: [verifyPostReq, verifyReadableFormData, verifyNewRequest, verifyRedditCommentVote],
    extractors: [extractRedditCommentVote],
    completers: [],
    registeredListener: null,
    registeredBlockingType: null
};

/**
 * Ensure that a tweet request contains a readable tweet.
 * @param requestDetails - the raw request
 * @returns - null when invalid, otherwise an object indicating whether the request comes from
 *  a service worker (not currently used).
 * @private
 */
function verifyTwitterTweet({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.status)) return null;
    if (!(requestDetails.requestBody.formData.status.length > 0)) return null;
    if (requestDetails.tabId >= 0) return {serviceWorker: false};
    if (requestDetails.documentUrl.endsWith("sw.js")) return {serviceWorker: true};
    else { return null; }
}

/**
 * Extract info from a tweet.
 * @param {Object} requestDetails
 * @returns {Object} - the tweet info extracted into an object
 * @private
 */
function extractTwitterTweet({requestDetails = null}) {
    const details = {};
    details.eventType = "tweet";
    details.eventTime = requestDetails.timeStamp;
    const tweetText = requestDetails.requestBody.formData["status"][0];
    details.postText = tweetText;
    if (requestDetails.requestBody.formData.attachment_url &&
        requestDetails.requestBody.formData.attachment_url.length > 0) {
        details.postAttachments = requestDetails.requestBody.formData.attachment_url;
    } else {
        details.postAttachments = null;
    }
    return details;
}

/**
 * Ensure that a retweet request contains a readable retweet.
 * @param requestDetails - the raw request
 * @returns - null when invalid, otherwise an object indicating whether the request comes from
 *  a service worker (not currently used).
 * @private
 */
function verifyTwitterRetweet({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.id)) return null;
    if (!(requestDetails.requestBody.formData.id.length > 0)) return null;
    if (requestDetails.tabId >= 0) return {serviceWorker: false};
    if (requestDetails.documentUrl.endsWith("sw.js")) return {serviceWorker: true};
}

/**
 * Extract info from a retweet.
 * @param {Object} requestDetails
 * @returns {Object} - the retweet info extracted into an object
 * @private
 */
function extractTwitterRetweet({requestDetails = null, eventTime = null}) {
    const tweetId = requestDetails.requestBody.formData.id[0];
    const details = {};
    details.eventType = "retweet";
    details.eventTimestamp = requestDetails.timeStamp;
    details.retweetedId = tweetId;
    details.eventTime = eventTime;
    return details;
}

/**
 * Ensure that a favorite request contains a readable favorite.
 * @param requestDetails - the raw request
 * @returns - null when invalid, otherwise an object indicating whether the request comes from
 *  a service worker (not currently used).
 * @private
 */
function verifyTwitterFavorite({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.id)) return null;
    if (!(requestDetails.requestBody.formData.id.length > 0)) return null;
    if (requestDetails.tabId >= 0) return {serviceWorker: false};
    if (requestDetails.documentUrl.endsWith("sw.js")) return {serviceWorker: true};
    return null;
}

/**
 * Extract info from a favorite.
 * @param {Object} requestDetails
 * @returns {Object} - the favorite info extracted into an object
 * @private
 */
function extractTwitterFavorite({requestDetails = null,
                                 details = null, verified = null,
                                 platform = null, eventType = null,
                                 blockingType = null, eventTime = null}) {
    const tweetId = requestDetails.requestBody.formData.id[0];
    details.eventType = "favorite";
    details.eventTimestamp = requestDetails.timeStamp;
    details.favoritedId = tweetId;
    details.eventTime = eventTime;
    return details;
}

/**
 * Request the content of a tweet, then filter and deduplicate the urls and return the relevant ones.
 * @param {string} tweet_id - the numerical ID of the tweet to retrieve
 * @returns - see Twitter API
 */
export function getTweetContent(tweetId) {
    return new Promise((resolve, reject) => {
        if (twitter_tabid < 0) { reject(); return; }
        browser.tabs.sendMessage(twitter_tabid,
            { tweetId: tweetId, x_csrf_token: twitter_x_csrf_token,
                authorization: twitter_authorization}).then((response) => {
                    try {
                        resolve(response.globalObjects.tweets);
                    } catch {resolve([]); }
                });
    });
}

/**
 * A content script within the page allows us to send fetch requests with the correct
 * cookies to get Twitter to respond. When the first Twitter tracker is registered,
 * register the content script and listen for it to tell us which tab ID it's inside.
 * We also need two additional fields to construct valid requests. To deal with these
 * changing periodically, we log them each time we see them sent.
 * @private
 */
function tweetContentInit() {
    if (tweetContentSetUp) { return; }
    tweetContentSetUp = true;
    browser.contentScripts.register({
        matches: ["https://twitter.com/*", "https://twitter.com/"],
        js: [{
            file: twitterContentScript
        }],
        runAt: "document_idle"
    });
    browser.webRequest.onBeforeSendHeaders.addListener((details) => {
        for (const header of details.requestHeaders) {
            if (header.name == "x-csrf-token") {
                twitter_x_csrf_token = header.value;
            }
            if (details.tabId >= 0) {
                twitter_tabid = details.tabId;
            }
            if (header.name == "authorization") {
                twitter_authorization = header.value;
            }
        }
    }, {urls: ["https://api.twitter.com/*"]}, ["requestHeaders"]);
}

/**
 * A content script inside the page allows us to seach for a post or send a request.
 * When the first Facebook tracker is registered, register the content script
 * and listen for it to tell us which tab ID it's in.
 * @private
 */
async function fbPostContentInit() {
    if (fbPostContentSetUp) { return; }
    fbPostContentSetUp = true;
    messaging.onMessage.addListener(
        (message, sender) => {
            if (message.platform == "facebook") {
                facebookTabId = sender.tab.id;
            }
        }, { type: "webScience.socialMediaActivity" });
    // Register the content script that will find posts inside the page when reshares happen
    await browser.contentScripts.register({
        matches: ["https://www.facebook.com/*", "https://www.facebook.com/"],
        js: [{
            file: facebookContentScript
        }],
        runAt: "document_start"
    });
}

/**
 * Parse a react request into an event.
 * @param requestDetails - the raw request
 * @returns - the parsed event
 * @private
 */
function extractFacebookReact({requestDetails = null, eventTime = null, verified = null}) {
    const reactionRequest = verified.reactionRequest;
    let postId = "";
    let groupId = "";
    let ownerId = "";
    try {
        const tracking = findFieldFacebook(reactionRequest, "tracking");
        postId = findFieldFacebook(tracking, "top_level_post_id");
        groupId = findFieldFacebook(tracking, "group_id");
        ownerId = findFieldFacebook(tracking, "content_owner_id_new");
    } catch(error) {
        const feedbackId = findFieldFacebook(reactionRequest, "feedback_id");
        if (feedbackId.startsWith("feedback:")) {
            postId = feedbackId.substring(9);
        }
    }
    const reaction = findFieldFacebook(reactionRequest, "feedback_reaction");
    let reactionType = "unknown";
    if (reaction == 0) { // removing reaction
        reactionType = "remove";
    } else if (reaction == 1) {
        reactionType = "like";
    } else if (reaction == 2) {
        reactionType = "love";
    } else if (reaction == 16) {
        reactionType = "care";
    } else if (reaction == 4) {
        reactionType = "haha";
    } else if (reaction == 3) {
        reactionType = "wow";
    } else if (reaction == 7) {
        reactionType = "sad";
    } else if (reaction == 8) {
        reactionType = "angry";
    }
    const details = {eventType: "react", eventTime: eventTime,
        postId: postId, groupId: groupId,
        ownerId: ownerId, reactionType: reactionType};
    return details;
}

/**
 * Check that a request is a valid react request
 * @param requestDetails - the raw request
 * @returns - null if the request is not a valid react, empty object otherwise
 * @private
 */
function verifyFacebookReact({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.fb_api_req_friendly_name)) { return null; }
    const friendlyName = findFieldFacebook(requestDetails, "fb_api_req_friendly_name");
    if (!(friendlyName.includes("UFI2FeedbackReactMutation") ||
          friendlyName.includes("CometUFIFeedbackReactMutation"))) {
        return null;
    }
    const reactionRequest = findFieldFacebook(requestDetails.requestBody.formData, "variables");
    return {reactionRequest};
}

/**
 * Check that a request is a valid post request
 * @param requestDetails - the raw request
 * @returns - null if the request is not a valid post, empty object otherwise
 * @private
 */
function verifyFacebookPost({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.variables)) { return null; }
    if (requestDetails.url.includes("api/graphql")) {
        const friendlyName = findFieldFacebook(requestDetails.requestBody.formData, "fb_api_req_friendly_name");
        if (!(friendlyName.includes("ComposerStoryCreateMutation"))) { return null; }
    }
    if (isThisPostAReshare(requestDetails)) { return null; }
    return {};
}

/**
 * Parse a post request into an event.
 * @param requestDetails - the raw request
 * @returns - the parsed event
 * @private
 */
function extractFacebookPost({requestDetails = null, eventTime = null}) {
    let postText = "";
    const postUrls = [];
    let audience = "unknown";
    let variables = findFieldFacebook(requestDetails.requestBody.formData, "variables", false);
    if (!Array.isArray(variables)) variables = [variables];
    for (let variable of variables) {
        let parsedVar;
        try {
            parsedVar = JSON.parse(variable);
        } catch {
            parsedVar = variable;
        }
        variable = parsedVar;

        // Check for urls in the post text itself
        const messageText = findFieldFacebook(findFieldFacebook(variable, "message"), "text");
        postText = postText.concat(messageText);
        audience = checkFacebookPostAudience(requestDetails);

        // Check for urls that are attachments instead of post text
        let attachments = findFieldFacebook(variable, "attachments", false);
        if (!(Array.isArray(attachments))) attachments = [attachments];
        for (const attachment of attachments) {
            const url = findFieldFacebook(findFieldFacebook(attachment, "share_params"), "canonical");
            postUrls.push(url);
        }
    }
    const details = {postTime: eventTime, postText: postText, audience: audience,
        postUrls: postUrls, eventType: "post", eventTime: eventTime};
    return details;
}

/**
 * Parse a comment request into an event.
 * @param requestDetails - the raw request
 * @returns - the parsed event
 * @private
 */
function extractFacebookComment({requestDetails = null, eventTime = null}) {
    const variables = findFieldFacebook(requestDetails.requestBody.formData, "variables");
    const tracking = findFieldFacebook(variables, "tracking");
    let postId = "";
    let groupId = "";
    let ownerId = "";
    postId = findFieldFacebook(tracking, "top_level_post_id");
    groupId = findFieldFacebook(tracking, "group_id");
    ownerId = findFieldFacebook(tracking, "content_owner_id_new");
    const commentText = findFieldFacebook(findFieldFacebook(variables, "message"), "text");
    const details = {
        eventType: "comment",
        postId: postId,
        groupId: groupId,
        ownerId: ownerId,
        eventTime: eventTime,
        commentText: commentText};
    return details;
}

/**
 * Check that a request is a valid comment request
 * @param requestDetails - the raw request
 * @returns - null if the request is not a valid comment, empty object otherwise
 * @private
 */
function verifyFacebookComment({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.fb_api_req_friendly_name)) { return null; }
    const friendlyName = findFieldFacebook(requestDetails, "fb_api_req_friendly_name");
    if (!(friendlyName.includes("UFI2CreateCommentMutation"))) { return null; }

    return {};
}
/**
 * @private
 */
function checkFacebookPostAudience(requestDetails) {
    let base_state = "unknown";
    let audience = "unknown";

    if (!(requestDetails && requestDetails.requestBody &&
        requestDetails.requestBody.formData.fb_api_req_friendly_name)) { return audience; }

    const variables = findFieldFacebook(requestDetails.requestBody.formData, "variables");
    const friendlyName = findFieldFacebook(requestDetails.requestBody.formData,
                                         "fb_api_req_friendly_name");
    if (friendlyName.includes("ComposerStoryCreateMutation")) {
        // this is a "post"-type event
        base_state =
            findFieldFacebook(
                findFieldFacebook(
                    findFieldFacebook(variables, "audience"),
                    "privacy"),
                "base_state");
    }

    if (friendlyName.includes("useCometFeedToFeedReshare_FeedToFeedMutation")) {
        // this is a "reshare"-type event

        base_state = findFieldFacebook(
            findFieldFacebook(
                findFieldFacebook(variables, "audiences"),
                "privacy"),
            "base_state");
    }

    if (base_state.toLowerCase() == "friends" ||
        base_state.toLowerCase() == "self") {
        audience = "restricted";
    } else if (base_state.toLowerCase() == "everyone") {
        audience = "public";
    }
    return audience;
}

/**
 * @private
 */
function findFieldFacebook(object, fieldName, enterArray = true, recurseLevel = 5) {
    if (recurseLevel <= 0) return null;
    if (object == null) return null;
    // if we're lucky, the field is here -- might be an array type, though
    if (typeof(object) == "object" && fieldName in object) {
        let result = null;
        if (enterArray && Array.isArray(object[fieldName])){
            result = object[fieldName][0];
        }
        result = object[fieldName];

        //nobody wants straight JSON back
        try {
            const parsed = JSON.parse(result)
            return parsed;
        } catch {
            return result;
        }
    }

    // maybe it's JSON?
    try {
        const parsed = JSON.parse(object);
        return findFieldFacebook(parsed, fieldName, enterArray, recurseLevel - 1);
    } catch {
        debugLog("failed parsing facebook content as JSON");
    }

    // if that fails, start checking children
    if (typeof(object) == "object") {
        for (const subObject in object) {
            const result = findFieldFacebook(object[subObject], fieldName, enterArray, recurseLevel - 1);
            if (result != null) return result;
        }
    }

    // not today.
    return null;
}


/**
 * Parse a reshare request into an event.
 * @param requestDetails - the raw request
 * @returns - the parsed event
 * @private
 */
async function extractFacebookReshare({requestDetails = null, verified = null, eventTime = null}) {
    // New FB
    if (requestDetails.url.includes("api/graphql")) {
        const details = {};
        const variables = findFieldFacebook(requestDetails.requestBody.formData, "variables");
        const message = findFieldFacebook(variables, "message");
        details.newPostMessage = message ? findFieldFacebook(message, "text") : "";
        details.attachedUrls = [];
        try {
            const attachments = findFieldFacebook(variables, "attachments");
            const link = findFieldFacebook(attachments, "link");
            const canonical = findFieldFacebook(link, "canonical");
            details.attachedUrls.push(canonical);
        } catch {
            debugLog("failed extracting links from facebook content");
        }
        const audience = checkFacebookPostAudience(requestDetails);
        const source = await getReshareInfo();
        details.audience = audience;
        details.source = source;
        details.eventType = "reshare";
        details.eventTime = eventTime;
        return details;
    }
}

/**
 * @private
 */
async function getReshareInfo() {
    return browser.tabs.sendMessage(facebookTabId, {"recentReshare": true}).then((response) => {
        return response;
    }, (e) => { console.log("ERROR", e); } );
}

/**
 * @private
 */
function isThisPostAReshare(requestDetails) {
    const friendlyName = findFieldFacebook(requestDetails.requestBody.formData,
        "fb_api_req_friendly_name");
    if (friendlyName.includes( "ComposerStoryCreateMutation")) {
        // sometimes things that look like posts are secretly reshares
        const composerType = findFieldFacebook(requestDetails.requestBody.formData,
            "composer_type");
        if (composerType == "share") {
            return true;
        }
        return false;
    }
    return false;
}

/**
 * Check that a request is a valid reshare request
 * @param requestDetails - the raw request
 * @returns - null if the request is not a valid reshare, empty object otherwise
 * @private
 */
function verifyFacebookReshare({requestDetails = null }) {
    if (requestDetails.url.includes("api/graphql")) {
        if (!(requestDetails.requestBody.formData.fb_api_req_friendly_name)) {
            return null;
        }
        if (requestDetails.requestBody.formData.fb_api_req_friendly_name.includes(
            "useCometFeedToFeedReshare_FeedToFeedMutation")) {
            return {};
        }
        if (isThisPostAReshare(requestDetails)) {
            return {};
        }
        return null;
    }
    let sharedFromPostId = null // the ID of the original post that's being shared
    let ownerId = null; // we need this if the main method of getting the contents doesn't work
    let newPostMessage = null // any content the user adds when sharing
    if (requestDetails.requestBody.formData &&
        typeof(requestDetails.requestBody.formData) == "object" &&
        "shared_from_post_id" in requestDetails.requestBody.formData &&
        requestDetails.requestBody.formData.shared_from_post_id.length > 0 &&
        "sharer_id" in requestDetails.requestBody.formData &&
        requestDetails.requestBody.formData.sharer_id.length > 0) {
        sharedFromPostId = requestDetails.requestBody.formData.shared_from_post_id[0];
        ownerId = requestDetails.requestBody.formData.sharer_id[0];
        return {sharedFromPostId: sharedFromPostId, ownerId: ownerId};
    }
    else {
        const parsedUrl = new URL(requestDetails.url);
        if (parsedUrl.searchParams.has("shared_from_post_id")) {
            sharedFromPostId = parsedUrl.searchParams.get("shared_from_post_id");
        }
        if (parsedUrl.searchParams.has("owner_id")) {
            ownerId = parsedUrl.searchParams.get("owner_id");
        }
        if (parsedUrl.searchParams.has("message")) {
            newPostMessage = parsedUrl.searchParams.get("message");
        }
        if (sharedFromPostId || ownerId || newPostMessage) {
            return {sharedFromPostId: sharedFromPostId,
                    ownerId: ownerId, newPostMessage: newPostMessage};
        }
    }
    return null;
}

/**
 * Get the contents and attachments of a Facebook post.
 * @param postId - the unique ID of the post
 * @param ownerId - the unique ID of the owner, or of the group, if the post is in a group
 */
export function getFacebookPostContents(postId) {
    return new Promise((resolve, reject) => {
        if (facebookTabId >= 0) {
            browser.tabs.sendMessage(facebookTabId, {"postId": postId}).then((response) => {
                resolve(response);
                return;
            }, (e) => { console.log("ERROR", e); } );
        } else reject();
    });
}

/**
 * Reddit posts don't currently have validation needs.
 * @private
 */
function verifyRedditPost({requestDetails = null}) {
    return {};
}

/**
 * Parse a Reddit post request into an object.
 * @param requestDetails - the raw request
 * @returns - the parsed object
 * @private
 */
function extractRedditPost({requestDetails = null}) {
    const shareTime = timing.now();
    const details = {};
    details.eventTime = shareTime;
    details.postBody = [];
    details.attachment = "";
    details.subredditName = "";

    let subredditName = "";
    if (typeof(requestDetails.requestBody.formData) == "object" &&
        "submit_type" in requestDetails.requestBody.formData &&
        requestDetails.requestBody.formData.submit_type.length > 0 &&
        requestDetails.requestBody.formData.submit_type[0] == "subreddit" &&
        "sr" in requestDetails.requestBody.formData &&
        requestDetails.requestBody.formData.sr.length > 0) {
        subredditName = requestDetails.requestBody.formData.sr[0];
        details.subredditName = subredditName;
    }

    // Handle if there's a URL attached to the post
    if (typeof(requestDetails.requestBody.formData) == "object" &&
        ("url" in requestDetails.requestBody.formData) &&
        (requestDetails.requestBody.formData["url"].length == 1)) {
        const postUrl = requestDetails.requestBody.formData["url"][0];
        details.attachment = postUrl;
    }

    details.postTitle = requestDetails.requestBody.formData.title[0];
    details.eventType = "post";

    /* check that this is a post whose body we can read */
    /* Reddit breaks up what the user types in the post. The "c" element of
     *  the "document" array is another array of objects with "e" and "t" attributes.
     * The "e" attribute tells you the type of element it is ("text" or "link"),
     *  and then the "t" attribute is the actual content. So, a post with the content:
     *  Here are some words www.example.com more words
     *  would generate a document[0].c with three elements:
     *  {"e":"text", "t":"Here are some words "}
     *  {"e":"link", "t":"www.example.com"}
     *  {"e":"text", "t":" more words"}
     *  (sometimes there are more attributes besides e and t -- but those are the ones that seem relevant)
     */
    if (typeof(requestDetails.requestBody.formData) == "object" &&
        "richtext_json" in requestDetails.requestBody.formData) {
        const postObject = JSON.parse(requestDetails.requestBody.formData["richtext_json"]);
        if (typeof(postObject) == "object" && "document" in postObject) {
            details.postBody = [];
            for (const paragraph of postObject.document) {
                if (typeof(paragraph) == "object" && "c" in paragraph) {
                    details.postBody.push(paragraph.c);
                }
            }
        }
    }
    return details;
}

/**
 * Check that a request is a valid Reddit comment
 * @param requestDetails - the raw request
 * @returns - null if the request is not valid, empty object otherwise
 * @private
 */
function verifyRedditComment({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.thing_id &&
        (requestDetails.requestBody.formData.richtext_json ||
           requestDetails.requestBody.formData.text))) { return null; }
    return {};
}

/**
 * Parse a Reddit comment request into an object.
 * @param requestDetails - the raw request
 * @returns - the parsed object
 * @private
 */
function extractRedditComment({requestDetails = null, eventTime = null}) {
    const details = {};
    details.eventTime = eventTime;
    details.eventType = "comment";
    details.postId = requestDetails.requestBody.formData.thing_id;
    details.commentText = requestDetails.requestBody.formData.richtext_json;
    details.otherCommentText = requestDetails.requestBody.formData.text;
    return details;
}

/**
 * Check that a request is a valid Reddit post vote
 * @param requestDetails - the raw request
 * @returns - null if the request is not valid, empty object otherwise
 * @private
 */
function verifyRedditPostVote({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.id &&
          requestDetails.requestBody.formData.id.length > 0 &&
          requestDetails.requestBody.formData.dir &&
          requestDetails.requestBody.formData.dir.length > 0 &&
          requestDetails.requestBody.formData.id[0].startsWith("t3_"))) {return null; }
    return {};
}

/**
 * Parse a Reddit post vote request into an object.
 * @param requestDetails - the raw request
 * @returns - the parsed object
 * @private
 */
function extractRedditPostVote({requestDetails = null, eventTime = null}) {
    const details = {};
    details.eventTime = eventTime;
    details.eventType = "postVote";
    details.vote = requestDetails.requestBody.formData.dir[0];
    details.postId = requestDetails.requestBody.formData.id[0];
    return details;
}

/**
 * Check that a request is a valid Reddit comment vote
 * @param requestDetails - the raw request
 * @returns - null if the request is not valid, empty object otherwise
 * @private
 */
function verifyRedditCommentVote({requestDetails = null}) {
    if (!(requestDetails.requestBody.formData.id &&
          requestDetails.requestBody.formData.id.length > 0 &&
          requestDetails.requestBody.formData.dir &&
          requestDetails.requestBody.formData.dir.length > 0 &&
          requestDetails.requestBody.formData.id[0].startsWith("t1_"))) {return null; }
    return {};
}

/**
 * Parse a Reddit comment vote request into an object.
 * @param requestDetails - the raw request
 * @returns - the parsed object
 * @private
 */
function extractRedditCommentVote({requestDetails = null, eventTime = null}) {
    return new Promise((resolve, reject) => {
        const details = {};
        details.eventTime = eventTime;
        details.eventType = "commentVote";
        details.vote = requestDetails.requestBody.formData.dir[0];
        details.commentId = requestDetails.requestBody.formData.id[0];

        getRedditThingContents(details.commentId).then((hydratedComment) => {
            details.postId = hydratedComment.data.children[0].data.link_id;
            details.commentContents = hydratedComment;
            resolve(details);
        });
    });
}

export function checkSubredditStatus(subredditName) {
    if (subredditName == "") return "unknown";
    return new Promise((resolve, reject) => {
        fetch(`https://www.reddit.com/r/${subredditName}/about.json`).then(responseFF => {
            responseFF.text().then(response => {
                const subredditInfo = JSON.parse(response);
                if (typeof(subredditInfo) == "object" &&
                    "error" in subredditInfo && subredditInfo.error == 403 &&
                    "reason" in subredditInfo && subredditInfo.reason == "private") {
                    resolve("private");
                    return;
                }
                if (typeof(subredditInfo) == "object" &&
                    "data" in subredditInfo &&
                    typeof(subredditInfo.data) == "object" &&
                    "subreddit_type" in subredditInfo.data) {
                    resolve(subredditInfo.data.subreddit_type);
                    return;
                }
                resolve("unknown");
            });
        });
    });
}

/**
 * Retrieve a reddit comment or post ("thing" is the official Reddit term)
 * @param thingId - the unique ID of the post or comment, with identifier ("t1_" or "t3_")
 * @returns - see Reddit API
 */
export function getRedditThingContents(thingId) {
    return new Promise((resolve, reject) => {
        const reqString = `https://www.reddit.com/api/info.json?id=${thingId}`;
        fetch(reqString).then((responseFF) => {
            responseFF.text().then((response) => {
                resolve(JSON.parse(response))
            });
        });
    });
}