diff --git a/DOCS.md b/DOCS.md index 2a3ab0e9..27c795af 100644 --- a/DOCS.md +++ b/DOCS.md @@ -579,49 +579,118 @@ __Arguments__ --------------------------------------- -### api.getThreadList(start, end, type, callback) +### api.getThreadList(limit, timestamp, tags, callback) -Will return information about threads. +Returns information about the user's threads. __Arguments__ -* `start`: Start index in the list of recently used threads. -* `end`: End index. -* `type`: Optional String, can be 'inbox', 'pending', or 'archived'. Inbox is default. -* `callback(err, arr)`: A callback called when the query is done (either with an error or with an confirmation object). `arr` is an array of thread object containing the following properties: +* `limit`: Limit the number of threads to fetch. +* `timestamp`: Request threads *before* this date. `null` means *now* +* `tags`: An array describing which folder to fetch. It should be one of these: + - `["INBOX"]` *(same as `[]`)* + - `["ARCHIVED"]` + - `["PENDING"]` + - `["OTHER"]` + - `["INBOX", "unread"]` + - `["ARCHIVED", "unread"]` + - `["PENDING", "unread"]` + - `["OTHER", "unread"]` + *if you find something new, let us know* + +* `callback(err, list)`: Callback called when the query is done (either with an error or with a proper result). `list` is of type _array_ and contains objects with the following properties: + +__Thread list__ + +| Key | Description | +|----------------------|-------------------------------------------------------------| +| threadID | ID of the thread | +| name | The name of the thread | +| unreadCount | Amount of unread messages in thread | +| messageCount | Amount of messages in thread | +| imageSrc | Link to the thread's image or `null` | +| emoji | The default emoji in thread (classic like is `null`) | +| color | Thread's message color in `RRGGBB` (default blue is `null`) | +| nicknames | An array of `{"userid": "1234", "nickname": "John Doe"}` | +| muteUntil | Timestamp until the mute expires or `null` | +| participants | An array of participants. See below | +| adminIDs | An array of thread admin IDs | +| folder | `INBOX`, `ARCHIVED`, `PENDING` or `OTHER` | +| isGroup | `true` or `false` | +| montageThread | `message_thread:000000` or `null` *not yet tested* | +| reactionsMuteMode | `REACTIONS_NOT_MUTED` or `REACTIONS_MUTED` | +| mentionsMuteMode | `MENTIONS_NOT_MUTED` or `MENTIONS_MUTED` | +| isArchived | `true` or `false` | +| isSubscribed | `true` or `false` | +| timestamp | timestamp in miliseconds | +| snippet | Snippet's text message | +| snippetAttachments | Attachments in snippet | +| snippetSender | ID of snippet sender | +| lastMessageTimestamp | timestamp in milliseconds | +| lastReadTimestamp | timestamp in milliseconds or `null` | +| cannotReplyReason | `null`, `"RECIPIENTS_NOT_LOADABLE"` or `"BLOCKED"` | + +__`participants` format__ + +`accountType` is one of the following: +- `"User"` +- `"Page"` +- `"UnavailableMessagingActor"` +- `"ReducedMessagingActor"` +(*there might be more*) + +| Key | Description | +|---------------------------|---------------------------------------------------------------| +| accountType | `"User"` | +| userID | ID of user | +| name | Full name of user | +| shortName | Short name of user (most likely first name) | +| gender | Either `"MALE"`, `"FEMALE"` or `"NEUTER"` | +| url | URL of the user's Facebook profile | +| profilePicture | URL of the profile picture | +| username | Username of user or `null` | +| isViewerFriend | Is the user a friend of you? | +| isMessengerUser | Does the user use Messenger? | +| isVerified | Is the user verified? (Little blue tick mark) | +| isMessageBlockedByViewer | Is the user blocking messages from you? | +| isViewerCoworker | Is the user your coworker? *(not yet tested)* | +| isEmployee | `null`? *(not yet tested)* | + + +| Key | Description | +|---------------------------|---------------------------------------------------------------| +| accountType | `"Page"` | +| userID | ID of the page | +| name | Name of the fanpage | +| url | URL of the fanpage | +| profilePicture | URL of the profile picture | +| username | Username of user or `null` | +| acceptsMessengerUserFeedback | | +| isMessengerUser | Does the fanpage use Messenger? | +| isVerified | Is the fanpage verified? (Little blue tick mark) | +| isMessengerPlatformBot | Is the fanpage a bot | +| isMessageBlockedByViewer | Is the fanpage blocking messages from you? | + + +| Key | Description | +|---------------------------|---------------------------------------------------------------| +| accountType | **1** `"ReducedMessagingActor"` (account requres verification, messages are hidden) or **2** `"UnavailableMessagingActor"` (account disabled/removed) | +| userID | ID of the user | +| name | **1** Name of the user or **2** *Facebook User* in user's language | +| url | `null` | +| profilePicture | URL of the default Facebook profile picture | +| username | Username of user or `null` | +| acceptsMessengerUserFeedback | | +| isMessageBlockedByViewer | Is the user blocking messages from you? | + + +In a case that some account type is not supported, we return just this *but you can't rely on it* and log warning to the console +| Key | Description | +|--------------|-------------------------| +| accountType | type, can be anything | +| userID | ID of the account | +| name | Name of the account | -| Key | Description | -|----------|:-------------:| -| threadID | ID of the thread | -| participantIDs | Array of user IDs in the thread | -| name | Name of the thread. Usually the name of the user. In group chats, this will be empty if the name of the group chat is unset. | -| nicknames | Map of nicknames for members of the thread. If there are no nicknames set, this will be null. | -| snippet | This is the preview message for the thread and is usually the last message in the thread. | -| snippetAttachments | If the snippet uses attachments (e.g. emoji's), this will be an array of the attachments. | -| snippetSender | The ID of the author of the snippet | -| unreadCount | Number of unread messages | -| messageCount | Number of messages | -| imageSrc | URL to the group chat photo. Null if unset or a canonical thread. | -| timestamp | | -| serverTimestamp | | -| muteUntil | Timestamp at which the thread will no longer be muted. The timestamp will be -1 if the thread is muted indefinitely or null if the thread is not muted. | -| isCanonicalUser | True if the other user in a canonical thread is a user, false if the other user is a page or group. | -| isCanonical | True if the thread is a private chat to a single user, false for group chats. | -| isSubscribed | | -| folder | The folder that the thread is in. Can be one of: | -| isArchived | True if the thread is archived, false if not | -| recipientsLoadable | | -| hasEmailParticipant | | -| readOnly | | -| canReply | True if the current user can reply in the thread | -| cannotReplyReason | If canReply is false, this will be a string stating why. Null if canReply is true. | -| lastMessageTimestamp | Timestamp of the last message. | -| lastReadTimestamp | Timestamp of the last message that is marked as 'read' by the current user. | -| lastMessageType | Message type of the last message. | -| emoji | Object with key 'emoji' whose value is the emoji unicode character. Null if unset. | -| color | String form of the custom color in hexadecimal form. | -| adminIDs | Array of user IDs of the admins of the thread. Empty array if unset. | -| threadType | 1 for a canonical thread, 2 for a group chat thread. | --------------------------------------- diff --git a/index.js b/index.js index ce37bf7a..b5da3601 100644 --- a/index.js +++ b/index.js @@ -108,6 +108,7 @@ function buildAPI(globalOptions, html, jar) { 'setTitle', // Deprecated features + "getThreadListDeprecated", 'getThreadHistoryDeprecated', 'getThreadInfoDeprecated', ]; diff --git a/src/getThreadList.js b/src/getThreadList.js index 707a6b48..69559580 100644 --- a/src/getThreadList.js +++ b/src/getThreadList.js @@ -1,73 +1,186 @@ "use strict"; -var utils = require("../utils"); -var log = require("npmlog"); +const utils = require("../utils"); +const log = require("npmlog"); -module.exports = function(defaultFuncs, api, ctx) { - return function getThreadList(start, end, type, callback) { - if (utils.getType(callback) === "Undefined") { - if (utils.getType(end) !== "Number") { - throw { - error: "Please pass a number as a second argument." +function formatParticipants(participants) { + return participants.nodes.map((p)=>{ + p = p.messaging_actor; + switch (p["__typename"]) { + case "User": + return { + accountType: p["__typename"], + userID: utils.formatID(p.id.toString()), // do we need .toString()? when it is not a string? + name: p.name, + shortName: p.short_name, + gender: p.gender, // MALE, FEMALE + url: p.url, // how about making it profileURL + profilePicture: p.big_image_src.uri, + username: (p.username||null), + // TODO: maybe better names for these? + isViewerFriend: p.is_viewer_friend, // true/false + isMessengerUser: p.is_messenger_user, // true/false + isVerified: p.is_verified, // true/false + isMessageBlockedByViewer: p.is_message_blocked_by_viewer, // true/false + isViewerCoworker: p.is_viewer_coworker, // true/false + isEmployee: p.is_employee // null? when it is something other? can someone check? + }; + case "Page": + return { + accountType: p["__typename"], + userID: utils.formatID(p.id.toString()), // or maybe... pageID? + name: p.name, + url: p.url, + profilePicture: p.big_image_src.uri, + username: (p.username||null), + // uhm... better names maybe? + acceptsMessengerUserFeedback: p.accepts_messenger_user_feedback, // true/false + isMessengerUser: p.is_messenger_user, // true/false + isVerified: p.is_verified, // true/false + isMessengerPlatformBot: p.is_messenger_platform_bot, // true/false + isMessageBlockedByViewer: p.is_message_blocked_by_viewer, // true/false + }; + case "ReducedMessagingActor": + return { + accountType: p["__typename"], + userID: utils.formatID(p.id.toString()), + name: p.name, + url: p.url, // in this case it is null all the time + profilePicture: p.big_image_src.uri, // in this case it is default facebook photo, we could determine gender using it + username: (p.username||null), // maybe we could use it to generate profile URL? + isMessageBlockedByViewer: p.is_message_blocked_by_viewer, // true/false }; - } else if ( - utils.getType(type) === "Function" || - utils.getType(type) === "AsyncFunction" - ) { - callback = type; - type = "inbox"; //default to inbox - } else if (utils.getType(type) !== "String") { - throw { - error: - "Please pass a String as a third argument. Your options are: inbox, pending, and archived" + case "UnavailableMessagingActor": + return { + accountType: p["__typename"], + userID: utils.formatID(p.id.toString()), + name: p.name, // "Facebook User" in user's language + url: p.url, // in this case it is null all the time + profilePicture: p.big_image_src.uri, // in this case it is default facebook photo, we could determine gender using it + username: (p.username||null), // maybe we could use it to generate profile URL? + isMessageBlockedByViewer: p.is_message_blocked_by_viewer, // true/false }; - } else { - throw { - error: "getThreadList: need callback" + default: + log.warn("getThreadList", "Found participant with unsupported typename. Please open an issue at https://github.com/Schmavery/facebook-chat-api/issues\n" + JSON.stringify(p, null, 2)); + return { + accountType: p["__typename"], + userID: utils.formatID(p.id.toString()), + name: p.name || `[unknown ${p["__typename"]}]`, // probably it will always be something... but fallback to [unknown], just in case }; - } } + }); +} - if (type === "archived") { - type = "action:archived"; - } else if (type !== "inbox" && type !== "pending" && type !== "other") { - throw { - error: - "type can only be one of the following: inbox, pending, archived, other" - }; - } +// "FF8C0077" -> "8C0077" +function formatColor(color) { + if (color && color.match(/^(?:[0-9a-fA-F]{8})$/g)) { + return color.slice(2); + } + return color; +} - if (end <= start) end = start + 20; +function getThreadName(t) { + if (t.name || t.thread_key.thread_fbid) return t.name; - var form = { - client: "mercury" - }; + for (let p of t.all_participants.nodes) { + if (p.messaging_actor.id === t.thread_key.other_user_id) return p.messaging_actor.name; + } +} - form[type + "[offset]"] = start; - form[type + "[limit]"] = end - start; +function formatThreadList(data) { + return data.map((t) => { + return { + threadID: utils.formatID(t.thread_key.thread_fbid || t.thread_key.other_user_id), + name: getThreadName(t), + unreadCount: t.unread_count, + messageCount: t.messages_count, + imageSrc: t.image?t.image.uri:null, + emoji: t.customization_info?t.customization_info.emoji:null, + color: formatColor(t.customization_info?t.customization_info.outgoing_bubble_color:null), + nicknames: t.customization_info?t.customization_info.participant_customizations.map((u) => { return {"userID": u.participant_id, "nickname": u.nickname }; }):[], // TODO: simplify? + muteUntil: t.mute_until, + participants: formatParticipants(t.all_participants), + adminIDs: t.thread_admins.map(a => { return a.id; }), // feature from future? it is always an empty array - for more than a year (2018-03-22) + folder: t.folder, + isGroup: t.thread_type === "GROUP", + // rtc_call_data: t.rtc_call_data, // TODO: format and document this + // isPinProtected: t.is_pin_protected, // feature from future? always false (2018-03-22) + // customizationEnabled: t.customization_enabled, // TODO: always true? (was true even when customization_info was null) (2018-03-22) + // participantAddModeAsString: t.participant_add_mode_as_string, // "ADD" if "GROUP" and null if "ONE_TO_ONE". do we need it? it may be connected with adminIDs + montageThread: t.montage_thread?Buffer.from(t.montage_thread.id,"base64").toString():null, // base64 encoded string "message_thread:0000000000000000" + // it is not userID nor any other ID known to me... + // can somebody inspect it? where is it used? + reactionsMuteMode: t.reactions_mute_mode, + mentionsMuteMode: t.mentions_mute_mode, + isArchived: t.has_viewer_archived, + isSubscribed: t.is_viewer_subscribed, + timestamp: t.updated_time_precise, // in miliseconds + // isCanonicalUser: t.is_canonical_neo_user, // is it always false? + // TODO: how about putting snippet in another object? current implementation does not handle every possibile message type etc. + snippet: t.last_message.nodes[0].snippet, + snippetAttachments: t.last_message.nodes[0].extensible_attachment, // TODO: not sure if it works + snippetSender: utils.formatID((t.last_message.nodes[0].message_sender.messaging_actor.id || "").toString()), + lastMessageTimestamp: t.last_message.nodes[0].timestamp_precise, // timestamp in miliseconds + lastReadTimestamp: (t.last_read_receipt.nodes[0]?t.last_read_receipt.nodes[0].timestamp_precise:null), // timestamp in miliseconds + cannotReplyReason: t.cannot_reply_reason, // TODO: inspect possible values - if (ctx.globalOptions.pageID) { - form.request_user_id = ctx.globalOptions.pageID; + // @Legacy + threadType: t.thread_type == "GROUP" ? 2 : 1 // "GROUP" or "ONE_TO_ONE" + }; + }); +} + +module.exports = function(defaultFuncs, api, ctx) { + return function getThreadList(limit, timestamp, tags, callback) { // TODO: if no tags, use tags as callback + if (utils.getType(limit) !== "Number" || limit%1 !== 0 || limit <= 0) { + throw {error: "getThreadList: limit must be a positive integer"}; + } + if (utils.getType(timestamp) !== "Null" && + (utils.getType(timestamp) !== "Number" || limit%1 !== 0)) { + throw {error: "getThreadList: timestamp must be an integer or null"}; // or maybe not? not tested + } + if (utils.getType(tags) !== "Array" || limit%1 !== 0) { + throw {error: "getThreadList: tags must be an array"}; // or maybe not? not tested } + if (utils.getType(callback) !== "Function" && utils.getType(callback) !== "AsyncFunction") { + throw {error: "getThreadList: need callback"}; + } + + const form = { + "queries": JSON.stringify({ + "o0": { + // This doc_id was valid on 2018-03-22. + "doc_id": "1349387578499440", + "query_params": { + "limit": limit+(timestamp?1:0), + "before": timestamp, + "tags": tags, + "includeDeliveryReceipts": true, + "includeSeqID": false + } + } + }) + }; defaultFuncs - .post( - "https://www.facebook.com/ajax/mercury/threadlist_info.php", - ctx.jar, - form - ) + .post("https://www.facebook.com/api/graphqlbatch/", ctx.jar, form) .then(utils.parseAndCheckLogin(ctx, defaultFuncs)) - .then(function(resData) { - if (resData.error) { - throw resData; + .then((resData) => { + require("fs").writeFile("threadList_raw_clear.json", JSON.stringify(resData), (err) => { if (err) throw err; }); + if (resData[resData.length - 1].error_results > 0) { + throw resData[0].o0.errors; + } + + if (resData[resData.length - 1].successful_results === 0) { + throw {error: "getThreadList: there was no successful_results", res: resData}; + } + + if (timestamp) { + resData[0].o0.data.viewer.message_threads.nodes.shift(); } - log.verbose("getThreadList", JSON.stringify(resData.payload.threads)); - return callback( - null, - (resData.payload.threads || []).map(utils.formatThread) - ); + callback(null, formatThreadList(resData[0].o0.data.viewer.message_threads.nodes)); }) - .catch(function(err) { + .catch((err) => { log.error("getThreadList", err); return callback(err); }); diff --git a/src/getThreadListDeprecated.js b/src/getThreadListDeprecated.js new file mode 100644 index 00000000..707a6b48 --- /dev/null +++ b/src/getThreadListDeprecated.js @@ -0,0 +1,75 @@ +"use strict"; + +var utils = require("../utils"); +var log = require("npmlog"); + +module.exports = function(defaultFuncs, api, ctx) { + return function getThreadList(start, end, type, callback) { + if (utils.getType(callback) === "Undefined") { + if (utils.getType(end) !== "Number") { + throw { + error: "Please pass a number as a second argument." + }; + } else if ( + utils.getType(type) === "Function" || + utils.getType(type) === "AsyncFunction" + ) { + callback = type; + type = "inbox"; //default to inbox + } else if (utils.getType(type) !== "String") { + throw { + error: + "Please pass a String as a third argument. Your options are: inbox, pending, and archived" + }; + } else { + throw { + error: "getThreadList: need callback" + }; + } + } + + if (type === "archived") { + type = "action:archived"; + } else if (type !== "inbox" && type !== "pending" && type !== "other") { + throw { + error: + "type can only be one of the following: inbox, pending, archived, other" + }; + } + + if (end <= start) end = start + 20; + + var form = { + client: "mercury" + }; + + form[type + "[offset]"] = start; + form[type + "[limit]"] = end - start; + + if (ctx.globalOptions.pageID) { + form.request_user_id = ctx.globalOptions.pageID; + } + + defaultFuncs + .post( + "https://www.facebook.com/ajax/mercury/threadlist_info.php", + ctx.jar, + form + ) + .then(utils.parseAndCheckLogin(ctx, defaultFuncs)) + .then(function(resData) { + if (resData.error) { + throw resData; + } + log.verbose("getThreadList", JSON.stringify(resData.payload.threads)); + return callback( + null, + (resData.payload.threads || []).map(utils.formatThread) + ); + }) + .catch(function(err) { + log.error("getThreadList", err); + return callback(err); + }); + }; +};