@eryxenx/insta-chat 2.0.11 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/DOCS.md +53 -11
- package/README.md +83 -3
- package/index.d.ts +103 -0
- package/index.js +6 -0
- package/package.json +2 -2
- package/src/api/realtime/listen.js +3 -1
- package/src/api/users/getProfileBio.js +33 -0
- package/src/api/users/getProfilePictureV2.js +54 -0
- package/src/api/users/getSelfInfo.js +27 -0
- package/src/api/users/getUserFriendship.js +42 -0
- package/src/api/users/getUserID.js +31 -0
- package/src/api/users/getUserInfoV2.js +62 -0
- package/src/api/users/getUserProfile.js +26 -0
- package/src/api.js +116 -2
- package/src/app/InstaClient.js +23 -0
- package/src/app/createFcaClient.js +13 -1
- package/src/constants.js +10 -1
- package/src/methods/inbox.js +82 -1
- package/src/methods/media.js +28 -14
- package/src/methods/user.js +180 -4
- package/src/profileParse.js +250 -0
- package/src/realtime/bridge.js +76 -0
- package/src/realtime/client.js +376 -0
- package/src/realtime/listener.js +86 -0
- package/src/realtime/mqttot.js +609 -0
package/src/methods/user.js
CHANGED
|
@@ -5,16 +5,37 @@
|
|
|
5
5
|
* Original author credit must be retained in all copies and derivatives.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { EP } = require("../constants");
|
|
8
|
+
const { EP, WEB, DOC_ID } = require("../constants");
|
|
9
|
+
const { parseProfilePage, normalizeWebProfileInfo, normalizeGraphqlUser, normalizeInboxUser, normalize } = require("../profileParse");
|
|
10
|
+
|
|
11
|
+
// A small cache so a profile fetched once (from whichever source answered) is
|
|
12
|
+
// reused instead of re-hit, and a per-user failure is not retried in a tight
|
|
13
|
+
// loop. Instagram rate-limits profile reads aggressively for web sessions.
|
|
14
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
9
15
|
|
|
10
16
|
class UserMethods {
|
|
11
|
-
constructor(http) {
|
|
17
|
+
constructor(http, getSelf, getLearned) {
|
|
12
18
|
this.http = http;
|
|
19
|
+
// Resolves the logged-in account ({ id, username }) so a profile lookup by
|
|
20
|
+
// the bot's own id can find its handle without a rate-limited REST call.
|
|
21
|
+
this.getSelf = typeof getSelf === "function" ? getSelf : null;
|
|
22
|
+
// Returns a raw user record already seen in the inbox/thread views for a pk,
|
|
23
|
+
// interop fbid or @handle — the offline source that survives rate-limiting.
|
|
24
|
+
this.getLearned = typeof getLearned === "function" ? getLearned : null;
|
|
25
|
+
this._profileCache = new Map(); // key -> { at, value }
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
// The id-only lookup. /api/v1/users/<id>/info/ is a REST path Instagram 302s
|
|
29
|
+
// to the login page for a web session (the HTTP layer reads any 3xx as an
|
|
30
|
+
// expired session), so this is best-effort: callers that need a full profile
|
|
31
|
+
// should use getProfile().
|
|
15
32
|
async getInfo(userID) {
|
|
16
|
-
|
|
17
|
-
|
|
33
|
+
try {
|
|
34
|
+
const res = await this.http.get(EP.USER_INFO(userID));
|
|
35
|
+
return res?.user || null;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
18
39
|
}
|
|
19
40
|
|
|
20
41
|
async getInfoByUsername(username) {
|
|
@@ -59,6 +80,161 @@ class UserMethods {
|
|
|
59
80
|
return [];
|
|
60
81
|
}
|
|
61
82
|
}
|
|
83
|
+
|
|
84
|
+
// --------------------------------------------------------------- profiles
|
|
85
|
+
// Full profile for a user id or @handle. Tries every source a web session can
|
|
86
|
+
// actually read, in order, and merges whatever each returns so the result is
|
|
87
|
+
// as complete as Instagram allows:
|
|
88
|
+
//
|
|
89
|
+
// 1. the user record already learned from the inbox/thread views (free, and
|
|
90
|
+
// the only source that still answers once the profile REST paths 429)
|
|
91
|
+
// 2. web_profile_info REST (canonical: follower/media counts, verified, ...)
|
|
92
|
+
// 3. the logged-in account's own page HTML (bio, hd picture, account flags)
|
|
93
|
+
//
|
|
94
|
+
// Never throws: an id that cannot be resolved returns null, and a source that
|
|
95
|
+
// 302s/429s is simply skipped — a profile lookup must not surface as a dead
|
|
96
|
+
// session.
|
|
97
|
+
async getProfile(idOrUsername, opts = {}) {
|
|
98
|
+
const raw = String(idOrUsername == null ? "" : idOrUsername).trim().replace(/^@/, "");
|
|
99
|
+
if (!raw) return null;
|
|
100
|
+
const byId = /^\d+$/.test(raw);
|
|
101
|
+
const key = (byId ? "id:" : "user:") + raw.toLowerCase();
|
|
102
|
+
|
|
103
|
+
const cached = this._profileCache.get(key);
|
|
104
|
+
if (!opts.refresh && cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
|
|
105
|
+
|
|
106
|
+
let username = byId ? null : raw;
|
|
107
|
+
const parts = [];
|
|
108
|
+
|
|
109
|
+
// 0) Whatever the inbox/thread views already told us about this user.
|
|
110
|
+
if (this.getLearned) {
|
|
111
|
+
const learned = this.getLearned(raw);
|
|
112
|
+
if (learned) {
|
|
113
|
+
const norm = normalizeInboxUser(learned);
|
|
114
|
+
if (norm) parts.push(norm);
|
|
115
|
+
if (!username && learned.username) username = learned.username;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 1) Canonical REST profile (username only). Also resolves an id -> username.
|
|
120
|
+
if (!username && byId) {
|
|
121
|
+
const self = this.getSelf && this.getSelf();
|
|
122
|
+
if (self && String(self.id) === raw && self.username) {
|
|
123
|
+
// The logged-in account answers offline (the cookie carries its handle).
|
|
124
|
+
username = self.username;
|
|
125
|
+
} else {
|
|
126
|
+
const u = await this.getInfo(raw).catch(() => null);
|
|
127
|
+
if (u?.username) username = u.username;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (username) {
|
|
131
|
+
const rest = await this._webProfileInfo(username);
|
|
132
|
+
if (rest) parts.push(rest);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 1b) Profile GraphQL (PolarisProfilePageContentQuery). Usually rate-limited
|
|
136
|
+
// (429) for a web session, but when it does answer it carries the richest
|
|
137
|
+
// data: biography, counts, external url and the account flags.
|
|
138
|
+
if (username) {
|
|
139
|
+
const gql = await this._profileFromGraphQL(username);
|
|
140
|
+
if (gql) parts.push(gql);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2) Profile pages Instagram server-renders: the account's own profile and
|
|
144
|
+
// the home/inbox shell both embed the *viewer's* full PolarisViewer blob, so
|
|
145
|
+
// the logged-in account is always resolvable even when REST 302s/429s.
|
|
146
|
+
const self = this.getSelf && this.getSelf();
|
|
147
|
+
const isSelf = !!(self && self.id && String(self.id) === raw);
|
|
148
|
+
if (username) {
|
|
149
|
+
const page = await this._profileFromPage(username);
|
|
150
|
+
if (page) parts.push(page);
|
|
151
|
+
}
|
|
152
|
+
if (isSelf && !parts.some((p) => p && p.username)) {
|
|
153
|
+
const shell = await this._viewerProfileFromShell();
|
|
154
|
+
if (shell) parts.push(shell);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!parts.length) return null;
|
|
158
|
+
|
|
159
|
+
const merged = this._mergeProfiles(parts, { id: byId ? raw : null, username });
|
|
160
|
+
const entry = { at: Date.now(), value: merged };
|
|
161
|
+
this._profileCache.set(key, entry);
|
|
162
|
+
if (merged.username) this._profileCache.set("user:" + merged.username.toLowerCase(), entry);
|
|
163
|
+
if (merged.id) this._profileCache.set("id:" + merged.id, entry);
|
|
164
|
+
return merged;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async _webProfileInfo(username) {
|
|
168
|
+
try {
|
|
169
|
+
const res = await this.http.get(EP.WEB_PROFILE_INFO(username));
|
|
170
|
+
const user = res?.data?.user || res?.user;
|
|
171
|
+
return normalizeWebProfileInfo(user);
|
|
172
|
+
} catch (_) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async _profileFromPage(username) {
|
|
178
|
+
try {
|
|
179
|
+
const res = await this.http.client.get(`${WEB}/${encodeURIComponent(username)}/`, { maxRedirects: 5 });
|
|
180
|
+
return parseProfilePage(typeof res.data === "string" ? res.data : "", username);
|
|
181
|
+
} catch (_) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The viewer's own profile, read from the home shell Instagram server-renders
|
|
187
|
+
// for a logged-in session (it embeds the full PolarisViewer blob).
|
|
188
|
+
async _viewerProfileFromShell() {
|
|
189
|
+
try {
|
|
190
|
+
const res = await this.http.client.get(`${WEB}/`, { maxRedirects: 5 });
|
|
191
|
+
return parseProfilePage(typeof res.data === "string" ? res.data : "");
|
|
192
|
+
} catch (_) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Profile GraphQL. Best-effort: a 429 / "Rate limit exceeded" / rotated doc_id
|
|
198
|
+
// is swallowed so the caller simply falls through to the next source.
|
|
199
|
+
async _profileFromGraphQL(username) {
|
|
200
|
+
const referer = `${WEB}/${encodeURIComponent(username)}/`;
|
|
201
|
+
const docs = [
|
|
202
|
+
["PolarisProfilePageContentQuery", DOC_ID.PROFILE_PAGE_CONTENT],
|
|
203
|
+
["PolarisUserRootQuery", DOC_ID.PROFILE_USER_ROOT]
|
|
204
|
+
];
|
|
205
|
+
for (const [name, docId] of docs) {
|
|
206
|
+
try {
|
|
207
|
+
const res = await this.http.postGraphQL(name, docId, { username }, referer);
|
|
208
|
+
if (Array.isArray(res?.errors) && res.errors.length) continue;
|
|
209
|
+
const user = res?.data?.user;
|
|
210
|
+
const norm = normalizeGraphqlUser(user);
|
|
211
|
+
if (norm && norm.username) return norm;
|
|
212
|
+
} catch (_) {
|
|
213
|
+
// try the next operation
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Later sources fill only the fields earlier ones left null, so the canonical
|
|
220
|
+
// REST profile wins and the HTML/learned data tops it up.
|
|
221
|
+
_mergeProfiles(parts, fallback) {
|
|
222
|
+
const out = normalize({ id: fallback.id, username: fallback.username });
|
|
223
|
+
for (const part of parts) {
|
|
224
|
+
for (const [k, v] of Object.entries(part)) {
|
|
225
|
+
if (k === "raw") continue;
|
|
226
|
+
const empty = out[k] === null || out[k] === undefined || out[k] === "";
|
|
227
|
+
if (empty && v !== null && v !== undefined && v !== "") out[k] = v;
|
|
228
|
+
}
|
|
229
|
+
for (const flag of ["isVerified", "isPrivate", "isBusinessAccount", "isProfessionalAccount", "hasProfilePic"]) {
|
|
230
|
+
if (part[flag] === true) out[flag] = true;
|
|
231
|
+
}
|
|
232
|
+
if (!out.raw && part.raw) out.raw = part.raw;
|
|
233
|
+
}
|
|
234
|
+
if (!out.id && fallback.id) out.id = String(fallback.id);
|
|
235
|
+
if (!out.pk) out.pk = out.id;
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
62
238
|
}
|
|
63
239
|
|
|
64
240
|
module.exports = UserMethods;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* insta-chat - Unofficial Instagram Chat API
|
|
3
|
+
* Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
|
|
4
|
+
* Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
|
|
5
|
+
* Original author credit must be retained in all copies and derivatives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Parsers for the two profile sources this package can read with a web session:
|
|
9
|
+
//
|
|
10
|
+
// 1. /api/v1/users/web_profile_info/ - the canonical profile JSON
|
|
11
|
+
// (`data.user` with edge_followed_by.count, biography, is_verified, ...).
|
|
12
|
+
// 2. the profile page HTML - www.instagram.com/<username>/ embeds a
|
|
13
|
+
// `["PolarisViewer",[],{"data":{...}}]` blob with biography, full_name,
|
|
14
|
+
// profile_pic_url_hd and the account flags, but no follower counts.
|
|
15
|
+
//
|
|
16
|
+
// Both are normalized to one rich object so callers never branch on the source.
|
|
17
|
+
// The /api/v1/users/<id>/info/ REST path is deliberately not used: for a web
|
|
18
|
+
// session Instagram 302s it to the login page (which the HTTP layer reads as an
|
|
19
|
+
// expired session) or 429s it.
|
|
20
|
+
|
|
21
|
+
// Walks a JSON object literal starting at the first `{` at/after `from`,
|
|
22
|
+
// honouring strings and escapes, and returns the parsed value (or null).
|
|
23
|
+
function parseJsonAt(text, from) {
|
|
24
|
+
let i = text.indexOf("{", from);
|
|
25
|
+
if (i < 0) return null;
|
|
26
|
+
const start = i;
|
|
27
|
+
let depth = 0;
|
|
28
|
+
let inStr = false;
|
|
29
|
+
let esc = false;
|
|
30
|
+
for (; i < text.length; i++) {
|
|
31
|
+
const c = text[i];
|
|
32
|
+
if (inStr) {
|
|
33
|
+
if (esc) esc = false;
|
|
34
|
+
else if (c === "\\") esc = true;
|
|
35
|
+
else if (c === '"') inStr = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === '"') inStr = true;
|
|
39
|
+
else if (c === "{") depth++;
|
|
40
|
+
else if (c === "}") {
|
|
41
|
+
depth--;
|
|
42
|
+
if (depth === 0) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(text.slice(start, i + 1));
|
|
45
|
+
} catch (_) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function countOf(edge) {
|
|
55
|
+
const n = edge && edge.count;
|
|
56
|
+
return Number.isFinite(Number(n)) ? Number(n) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Accepts either a Relay field-count edge (`{ count: n }`) or a plain number.
|
|
60
|
+
function countOrEdge(v) {
|
|
61
|
+
if (v == null) return null;
|
|
62
|
+
if (typeof v === "object") return countOf(v);
|
|
63
|
+
return Number.isFinite(Number(v)) ? Number(v) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Maps a web_profile_info `data.user` (REST) to the normalized shape.
|
|
67
|
+
function normalizeWebProfileInfo(user) {
|
|
68
|
+
if (!user || typeof user !== "object") return null;
|
|
69
|
+
return normalize({
|
|
70
|
+
id: user.id || user.pk,
|
|
71
|
+
username: user.username,
|
|
72
|
+
fullName: user.full_name,
|
|
73
|
+
biography: user.biography,
|
|
74
|
+
externalUrl: user.external_url,
|
|
75
|
+
category: user.category_name || user.category,
|
|
76
|
+
isVerified: user.is_verified,
|
|
77
|
+
isPrivate: user.is_private,
|
|
78
|
+
isBusinessAccount: user.is_business_account,
|
|
79
|
+
isProfessionalAccount: user.is_professional_account,
|
|
80
|
+
profilePicUrl: user.profile_pic_url,
|
|
81
|
+
profilePicUrlHd: user.profile_pic_url_hd || user.profile_pic_url,
|
|
82
|
+
fbid: user.fbid,
|
|
83
|
+
followerCount: countOf(user.edge_followed_by),
|
|
84
|
+
followingCount: countOf(user.edge_follow),
|
|
85
|
+
mediaCount: countOf(user.edge_owner_to_timeline_media),
|
|
86
|
+
highlightReelCount: user.highlight_reel_count,
|
|
87
|
+
businessEmail: user.business_email,
|
|
88
|
+
businessPhone: user.business_phone_number,
|
|
89
|
+
publicEmail: user.public_email,
|
|
90
|
+
publicPhone: user.public_phone_number,
|
|
91
|
+
hasProfilePic: user.has_profile_pic,
|
|
92
|
+
raw: user
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Maps the GraphQL profile response (`data.user` from PolarisProfilePageContent-
|
|
97
|
+
// Query / PolarisUserRootQuery) to the normalized shape. The field names are the
|
|
98
|
+
// Relay ones, and the counts come back either as an edge or as a plain number,
|
|
99
|
+
// so both are accepted.
|
|
100
|
+
function normalizeGraphqlUser(user) {
|
|
101
|
+
if (!user || typeof user !== "object") return null;
|
|
102
|
+
const follower = user.follower_count ?? user.edge_followed_by;
|
|
103
|
+
const following = user.following_count ?? user.edge_follow;
|
|
104
|
+
const media = user.media_count ?? user.edge_owner_to_timeline_media;
|
|
105
|
+
return normalize({
|
|
106
|
+
id: user.id || user.pk || user.strong_id__,
|
|
107
|
+
username: user.username,
|
|
108
|
+
fullName: user.full_name,
|
|
109
|
+
biography: user.biography,
|
|
110
|
+
externalUrl: user.external_url || user.bio_links?.[0]?.url,
|
|
111
|
+
category: user.category_name || user.category,
|
|
112
|
+
isVerified: user.is_verified,
|
|
113
|
+
isPrivate: user.is_private,
|
|
114
|
+
isBusinessAccount: user.is_business_account,
|
|
115
|
+
isProfessionalAccount: user.is_professional_account,
|
|
116
|
+
profilePicUrl: user.profile_pic_url,
|
|
117
|
+
profilePicUrlHd: user.hd_profile_pic_url_info?.url || user.profile_pic_url_hd || user.profile_pic_url,
|
|
118
|
+
fbid: user.fbid,
|
|
119
|
+
followerCount: countOrEdge(follower),
|
|
120
|
+
followingCount: countOrEdge(following),
|
|
121
|
+
mediaCount: countOrEdge(media),
|
|
122
|
+
businessEmail: user.business_email,
|
|
123
|
+
businessPhone: user.business_phone_number,
|
|
124
|
+
publicEmail: user.public_email,
|
|
125
|
+
publicPhone: user.public_phone_number,
|
|
126
|
+
hasProfilePic: user.has_profile_pic ?? (!!user.profile_pic_url),
|
|
127
|
+
raw: user
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Maps the HTML-embedded PolarisViewer `data` to the normalized shape.
|
|
132
|
+
function normalizePolarisViewer(data) {
|
|
133
|
+
if (!data || typeof data !== "object" || !data.username) return null;
|
|
134
|
+
return normalize({
|
|
135
|
+
id: data.id,
|
|
136
|
+
username: data.username,
|
|
137
|
+
fullName: data.full_name,
|
|
138
|
+
biography: data.biography,
|
|
139
|
+
externalUrl: data.external_url,
|
|
140
|
+
category: data.category_name,
|
|
141
|
+
isVerified: data.is_verified,
|
|
142
|
+
isPrivate: data.is_private,
|
|
143
|
+
isBusinessAccount: data.is_business_account,
|
|
144
|
+
isProfessionalAccount: data.is_professional_account,
|
|
145
|
+
profilePicUrl: data.profile_pic_url,
|
|
146
|
+
profilePicUrlHd: data.profile_pic_url_hd || data.profile_pic_url,
|
|
147
|
+
fbid: data.fbid,
|
|
148
|
+
hasProfilePic: data.has_profile_pic,
|
|
149
|
+
raw: data
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Maps an inbox/thread introspection user record (id, username, full_name,
|
|
154
|
+
// profile_pic_url, is_verified, interop_messaging_user_fbid) to the normalized
|
|
155
|
+
// shape. This is the data the DM GraphQL surface reliably returns for the
|
|
156
|
+
// people a bot actually talks to, when the profile REST paths rate-limit.
|
|
157
|
+
function normalizeInboxUser(u) {
|
|
158
|
+
if (!u || typeof u !== "object") return null;
|
|
159
|
+
const username = u.username || null;
|
|
160
|
+
const id = u.id || u.pk || u.interop_messaging_user_fbid || null;
|
|
161
|
+
if (!username && !id) return null;
|
|
162
|
+
return normalize({
|
|
163
|
+
id,
|
|
164
|
+
username,
|
|
165
|
+
fullName: u.full_name,
|
|
166
|
+
profilePicUrl: u.profile_pic_url,
|
|
167
|
+
profilePicUrlHd: u.profile_pic_url_hd || u.profile_pic_url,
|
|
168
|
+
isVerified: u.is_verified,
|
|
169
|
+
isPrivate: u.is_private,
|
|
170
|
+
fbid: u.interop_messaging_user_fbid,
|
|
171
|
+
raw: u
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Pulls the profile out of a profile page's HTML. Returns null when the page is
|
|
176
|
+
// a login wall or carries no profile blob. `expectUsername`, when given, guards
|
|
177
|
+
// against the SPA shell embedding the *viewer's* profile for a page Instagram
|
|
178
|
+
// did not server-render: the blob is only trusted when it is the requested user.
|
|
179
|
+
function parseProfilePage(html, expectUsername) {
|
|
180
|
+
const text = typeof html === "string" ? html : "";
|
|
181
|
+
if (!text || /accounts\/login/i.test(text.slice(0, 2000))) return null;
|
|
182
|
+
for (const marker of ['"PolarisViewer",[],', '"PolarisProfilePageContentQuery"']) {
|
|
183
|
+
const at = text.indexOf(marker);
|
|
184
|
+
if (at < 0) continue;
|
|
185
|
+
const obj = parseJsonAt(text, at + marker.length);
|
|
186
|
+
const data = obj && (obj.data || obj);
|
|
187
|
+
const profile = normalizePolarisViewer(data);
|
|
188
|
+
if (!profile) continue;
|
|
189
|
+
if (expectUsername && String(profile.username).toLowerCase() !== String(expectUsername).toLowerCase()) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
return profile;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The single normalized profile shape every source maps to. Fields a source
|
|
198
|
+
// cannot supply stay null, so a caller can tell "unknown" from "false".
|
|
199
|
+
function normalize(p) {
|
|
200
|
+
if (!p) return null;
|
|
201
|
+
const id = p.id != null ? String(p.id) : null;
|
|
202
|
+
return {
|
|
203
|
+
id,
|
|
204
|
+
pk: id,
|
|
205
|
+
username: p.username || null,
|
|
206
|
+
fullName: p.fullName || null,
|
|
207
|
+
biography: p.biography || "",
|
|
208
|
+
externalUrl: p.externalUrl || null,
|
|
209
|
+
category: p.category || null,
|
|
210
|
+
isVerified: p.isVerified === true,
|
|
211
|
+
isPrivate: p.isPrivate === true,
|
|
212
|
+
isBusinessAccount: p.isBusinessAccount === true,
|
|
213
|
+
isProfessionalAccount: p.isProfessionalAccount === true,
|
|
214
|
+
hasProfilePic: p.hasProfilePic === true,
|
|
215
|
+
profilePicUrl: p.profilePicUrl || null,
|
|
216
|
+
profilePicUrlHd: p.profilePicUrlHd || p.profilePicUrl || null,
|
|
217
|
+
fbid: p.fbid != null ? String(p.fbid) : null,
|
|
218
|
+
followerCount: p.followerCount != null ? p.followerCount : null,
|
|
219
|
+
followingCount: p.followingCount != null ? p.followingCount : null,
|
|
220
|
+
mediaCount: p.mediaCount != null ? p.mediaCount : null,
|
|
221
|
+
highlightReelCount: p.highlightReelCount != null ? p.highlightReelCount : null,
|
|
222
|
+
businessEmail: p.businessEmail || null,
|
|
223
|
+
businessPhone: p.businessPhone || null,
|
|
224
|
+
publicEmail: p.publicEmail || null,
|
|
225
|
+
publicPhone: p.publicPhone || null,
|
|
226
|
+
raw: p.raw || null
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Instagram's CDN resizes a profile picture from the URL's stp= parameter. This
|
|
231
|
+
// returns the same picture at a requested square size, so callers get a crisp
|
|
232
|
+
// avatar without a second API call.
|
|
233
|
+
function profilePictureAtSize(url, size) {
|
|
234
|
+
if (!url || !size) return url;
|
|
235
|
+
const px = Math.max(1, Math.round(Number(size) || 0));
|
|
236
|
+
if (!px) return url;
|
|
237
|
+
const stp = `dst-jpg_s${px}x${px}_tt6`;
|
|
238
|
+
if (/[?&]stp=[^&]+/.test(url)) return url.replace(/([?&])stp=[^&]+/, `$1stp=${stp}`);
|
|
239
|
+
return `${url}${url.includes("?") ? "&" : "?"}stp=${stp}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
parseProfilePage,
|
|
244
|
+
normalizeWebProfileInfo,
|
|
245
|
+
normalizeGraphqlUser,
|
|
246
|
+
normalizePolarisViewer,
|
|
247
|
+
normalizeInboxUser,
|
|
248
|
+
normalize,
|
|
249
|
+
profilePictureAtSize
|
|
250
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* insta-chat - Unofficial Instagram Chat API
|
|
3
|
+
* Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
|
|
4
|
+
* Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
|
|
5
|
+
* Original author credit must be retained in all copies and derivatives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Turns a realtime MQTT message-sync patch into the same raw inbox event the
|
|
9
|
+
// poller produces, so listen() can hand either source to the same callback and
|
|
10
|
+
// the EventNormalizer never needs to know which one delivered it.
|
|
11
|
+
//
|
|
12
|
+
// The broker pushes a patch per Direct change:
|
|
13
|
+
// { op: "add", path: "/direct_v2/threads/<tid>/items/<iid>",
|
|
14
|
+
// value: "<json item>" }
|
|
15
|
+
// Only a new item under /items/ is an inbound message; activity indicators,
|
|
16
|
+
// seen state and reactions share the /direct_v2/threads/ prefix and are ignored.
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const { attachmentsFromContent } = require("../messageContent");
|
|
21
|
+
|
|
22
|
+
const ITEM_PATH_RE = /^\/direct_v2\/threads\/([^/]+)\/items\/([^/]+)$/;
|
|
23
|
+
|
|
24
|
+
function isMessagePatch(path, op) {
|
|
25
|
+
if (!ITEM_PATH_RE.test(String(path || ""))) return false;
|
|
26
|
+
// op is "add" for a new item; older captures omit it, so an absent op is fine.
|
|
27
|
+
return !op || String(op).toLowerCase() === "add";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Builds a raw event from a message-sync patch, or null when the patch is not an
|
|
31
|
+
// actionable inbound message (our own send, a reaction, a non-item change).
|
|
32
|
+
function rawEventFromMessageSync(event, selfId) {
|
|
33
|
+
const message = event && event.message ? event.message : event;
|
|
34
|
+
if (!message || typeof message !== "object") return null;
|
|
35
|
+
|
|
36
|
+
const match = String(message.path || "").match(ITEM_PATH_RE);
|
|
37
|
+
if (!match || !isMessagePatch(message.path, message.op)) return null;
|
|
38
|
+
|
|
39
|
+
const threadID = String(match[1]);
|
|
40
|
+
const messageID = String(message.item_id || match[2]);
|
|
41
|
+
|
|
42
|
+
const senderID = message.user_id != null ? String(message.user_id) : null;
|
|
43
|
+
// The broker echoes our own sends back over message-sync; drop them.
|
|
44
|
+
if (senderID && selfId && senderID === String(selfId)) return null;
|
|
45
|
+
|
|
46
|
+
const itemType = String(message.item_type || message.content_type || "text").toLowerCase();
|
|
47
|
+
if (/reaction|action_log|like|video_call/.test(itemType)) return null;
|
|
48
|
+
|
|
49
|
+
const body = message.text || message.text_body || "";
|
|
50
|
+
let attachments = [];
|
|
51
|
+
if (itemType !== "text") {
|
|
52
|
+
attachments = attachmentsFromContent(message.media || message.content || message);
|
|
53
|
+
}
|
|
54
|
+
if (itemType !== "text" && attachments.length === 0) {
|
|
55
|
+
// The item carried no parseable media URL; let the normalizer/bot fetch the
|
|
56
|
+
// thread history instead of emitting a message with nothing actionable.
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
type: "message",
|
|
62
|
+
threadID,
|
|
63
|
+
senderID,
|
|
64
|
+
messageID,
|
|
65
|
+
body,
|
|
66
|
+
attachments,
|
|
67
|
+
itemType,
|
|
68
|
+
repliedToItemId: message.replied_to_item_id || null,
|
|
69
|
+
repliedToMessage: null,
|
|
70
|
+
clientContext: message.client_context || null,
|
|
71
|
+
offlineThreadingId: message.offline_threading_id != null ? String(message.offline_threading_id) : null,
|
|
72
|
+
timestamp: Number(message.timestamp || message.timestamp_ms) || Date.now()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { rawEventFromMessageSync, isMessagePatch, ITEM_PATH_RE };
|