instagram-mcp-server 1.6.6
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/.env.example +2 -0
- package/.github/dependabot.yml +50 -0
- package/.github/workflows/ci.yml +51 -0
- package/.github/workflows/release.yml +200 -0
- package/CONTRIBUTING.md +38 -0
- package/LICENSE +21 -0
- package/Makefile +16 -0
- package/README.md +141 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +140 -0
- package/dist/client.test.d.ts +10 -0
- package/dist/client.test.js +212 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +87 -0
- package/dist/errors.test.d.ts +9 -0
- package/dist/errors.test.js +164 -0
- package/dist/first-comment.test.d.ts +10 -0
- package/dist/first-comment.test.js +56 -0
- package/dist/handlers.test.d.ts +23 -0
- package/dist/handlers.test.js +355 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +627 -0
- package/dist/lib/index.d.ts +6 -0
- package/dist/lib/index.js +5 -0
- package/dist/lib/insights.d.ts +55 -0
- package/dist/lib/insights.js +59 -0
- package/dist/rate-limiter.d.ts +72 -0
- package/dist/rate-limiter.js +219 -0
- package/dist/rate-limiter.test.d.ts +1 -0
- package/dist/rate-limiter.test.js +153 -0
- package/dist/response.d.ts +24 -0
- package/dist/response.js +35 -0
- package/dist/response.test.d.ts +1 -0
- package/dist/response.test.js +71 -0
- package/dist/sanitize.d.ts +17 -0
- package/dist/sanitize.js +27 -0
- package/dist/sanitize.test.d.ts +1 -0
- package/dist/sanitize.test.js +43 -0
- package/dist/tools.test.d.ts +14 -0
- package/dist/tools.test.js +188 -0
- package/package.json +32 -0
- package/src/client.test.ts +285 -0
- package/src/client.ts +204 -0
- package/src/errors.test.ts +299 -0
- package/src/errors.ts +108 -0
- package/src/first-comment.test.ts +75 -0
- package/src/handlers.test.ts +460 -0
- package/src/index.ts +960 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/insights.ts +182 -0
- package/src/rate-limiter.test.ts +185 -0
- package/src/rate-limiter.ts +257 -0
- package/src/response.test.ts +80 -0
- package/src/response.ts +43 -0
- package/src/sanitize.test.ts +52 -0
- package/src/sanitize.ts +35 -0
- package/src/tools.test.ts +251 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +10 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instagram Graph API client.
|
|
3
|
+
*
|
|
4
|
+
* Raw fetch wrapper against https://graph.instagram.com/v22.0/.
|
|
5
|
+
* No SDK — keeps dependencies minimal (Occam's Razor).
|
|
6
|
+
* Client instances are cached by credential hash.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
const GRAPH_API_BASE = "https://graph.facebook.com/v21.0";
|
|
10
|
+
const CLIENT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
11
|
+
const clientCache = new Map();
|
|
12
|
+
function credentialHash(creds) {
|
|
13
|
+
const raw = `${creds.accessToken}:${creds.accountId}`;
|
|
14
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get or create a cached Instagram client.
|
|
18
|
+
*/
|
|
19
|
+
export function createClient(creds) {
|
|
20
|
+
const key = credentialHash(creds);
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
// Evict stale entries on every call
|
|
23
|
+
for (const [k, v] of clientCache) {
|
|
24
|
+
if (now - v.createdAt >= CLIENT_TTL_MS) {
|
|
25
|
+
clientCache.delete(k);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const cached = clientCache.get(key);
|
|
29
|
+
if (cached)
|
|
30
|
+
return cached.client;
|
|
31
|
+
const client = new InstagramClient(creds);
|
|
32
|
+
clientCache.set(key, { client, createdAt: now });
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
35
|
+
function isGraphApiError(body) {
|
|
36
|
+
return (typeof body === "object" &&
|
|
37
|
+
body !== null &&
|
|
38
|
+
"error" in body &&
|
|
39
|
+
typeof body.error === "object" &&
|
|
40
|
+
typeof body.error.message === "string");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Error thrown for Graph API failures. Carries structured error data
|
|
44
|
+
* for suggestAction() to inspect.
|
|
45
|
+
*/
|
|
46
|
+
export class InstagramApiError extends Error {
|
|
47
|
+
status;
|
|
48
|
+
code;
|
|
49
|
+
errorSubcode;
|
|
50
|
+
errorType;
|
|
51
|
+
retryAfter;
|
|
52
|
+
constructor(status, apiError, retryAfter) {
|
|
53
|
+
super(apiError.message);
|
|
54
|
+
this.name = "InstagramApiError";
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.code = apiError.code;
|
|
57
|
+
this.errorSubcode = apiError.error_subcode;
|
|
58
|
+
this.errorType = apiError.type;
|
|
59
|
+
this.retryAfter = retryAfter;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class InstagramClient {
|
|
63
|
+
accessToken;
|
|
64
|
+
accountId;
|
|
65
|
+
constructor(creds) {
|
|
66
|
+
this.accessToken = creds.accessToken;
|
|
67
|
+
this.accountId = creds.accountId;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Make a GET request to the Graph API.
|
|
71
|
+
*/
|
|
72
|
+
async get(path, params) {
|
|
73
|
+
const url = new URL(`${GRAPH_API_BASE}${path}`);
|
|
74
|
+
url.searchParams.set("access_token", this.accessToken);
|
|
75
|
+
if (params) {
|
|
76
|
+
for (const [key, value] of Object.entries(params)) {
|
|
77
|
+
url.searchParams.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
signal: AbortSignal.timeout(30_000),
|
|
82
|
+
});
|
|
83
|
+
return this.handleResponse(res);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Make a POST request to the Graph API.
|
|
87
|
+
*/
|
|
88
|
+
async post(path, body) {
|
|
89
|
+
const url = new URL(`${GRAPH_API_BASE}${path}`);
|
|
90
|
+
url.searchParams.set("access_token", this.accessToken);
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
95
|
+
signal: AbortSignal.timeout(30_000),
|
|
96
|
+
});
|
|
97
|
+
return this.handleResponse(res);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Make a DELETE request to the Graph API.
|
|
101
|
+
*/
|
|
102
|
+
async delete(path) {
|
|
103
|
+
const url = new URL(`${GRAPH_API_BASE}${path}`);
|
|
104
|
+
url.searchParams.set("access_token", this.accessToken);
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
method: "DELETE",
|
|
107
|
+
signal: AbortSignal.timeout(30_000),
|
|
108
|
+
});
|
|
109
|
+
return this.handleResponse(res);
|
|
110
|
+
}
|
|
111
|
+
async handleResponse(res) {
|
|
112
|
+
let body;
|
|
113
|
+
try {
|
|
114
|
+
body = await res.json();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Non-JSON response (HTML error page during outage, empty body, etc.)
|
|
118
|
+
throw new InstagramApiError(res.status, {
|
|
119
|
+
message: `HTTP ${res.status}: Response is not valid JSON (likely an outage or proxy error)`,
|
|
120
|
+
type: "ParseError",
|
|
121
|
+
code: res.status,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const retryAfterHeader = res.headers.get("Retry-After");
|
|
126
|
+
const seconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
|
|
127
|
+
const retryAfter = !isNaN(seconds) ? seconds : undefined;
|
|
128
|
+
if (isGraphApiError(body)) {
|
|
129
|
+
throw new InstagramApiError(res.status, body.error, retryAfter);
|
|
130
|
+
}
|
|
131
|
+
// Non-standard error response — wrap in a generic error
|
|
132
|
+
throw new InstagramApiError(res.status, {
|
|
133
|
+
message: `HTTP ${res.status}: ${JSON.stringify(body)}`,
|
|
134
|
+
type: "UnknownError",
|
|
135
|
+
code: res.status,
|
|
136
|
+
}, retryAfter);
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Graph API HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Uses vi.stubGlobal("fetch", ...) to stub network calls. Every test resets
|
|
5
|
+
* the stub in afterEach to prevent leakage. The most important test here is
|
|
6
|
+
* the one asserting GRAPH_API_BASE points at graph.facebook.com — it's a
|
|
7
|
+
* regression guard for Bug #1 discovered during live testing (the scaffold
|
|
8
|
+
* incorrectly used graph.instagram.com which rejects Facebook Login tokens).
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Graph API HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Uses vi.stubGlobal("fetch", ...) to stub network calls. Every test resets
|
|
5
|
+
* the stub in afterEach to prevent leakage. The most important test here is
|
|
6
|
+
* the one asserting GRAPH_API_BASE points at graph.facebook.com — it's a
|
|
7
|
+
* regression guard for Bug #1 discovered during live testing (the scaffold
|
|
8
|
+
* incorrectly used graph.instagram.com which rejects Facebook Login tokens).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
11
|
+
import { createClient, InstagramApiError, InstagramClient, } from "./client.js";
|
|
12
|
+
// Reach into the module's source to read the base URL constant.
|
|
13
|
+
// We assert on actual request URLs below, which is the real contract.
|
|
14
|
+
const creds = {
|
|
15
|
+
accessToken: "EAALtest123",
|
|
16
|
+
accountId: "17841400000000000",
|
|
17
|
+
};
|
|
18
|
+
function mockFetchOk(body, init = {}) {
|
|
19
|
+
return vi.fn(async () => new Response(JSON.stringify(body), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
...init,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
function mockFetchError(status, body, headers = {}) {
|
|
26
|
+
return vi.fn(async () => new Response(JSON.stringify(body), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { "content-type": "application/json", ...headers },
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
describe("InstagramClient — URL construction", () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
});
|
|
35
|
+
it("GET builds URL against graph.facebook.com/v21.0 (REGRESSION: Bug #1)", async () => {
|
|
36
|
+
const fetchMock = mockFetchOk({ data: [] });
|
|
37
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
38
|
+
const client = new InstagramClient(creds);
|
|
39
|
+
await client.get("/17841400000000000/insights");
|
|
40
|
+
const [url] = fetchMock.mock.calls[0];
|
|
41
|
+
expect(url.toString()).toMatch(/^https:\/\/graph\.facebook\.com\/v21\.0\/17841400000000000\/insights\?/);
|
|
42
|
+
});
|
|
43
|
+
it("GET puts access_token and extra params in query string", async () => {
|
|
44
|
+
const fetchMock = mockFetchOk({ data: [] });
|
|
45
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
46
|
+
const client = new InstagramClient(creds);
|
|
47
|
+
await client.get("/17841400000000000/insights", {
|
|
48
|
+
metric: "reach,profile_views",
|
|
49
|
+
period: "day",
|
|
50
|
+
});
|
|
51
|
+
const url = fetchMock.mock.calls[0][0];
|
|
52
|
+
expect(url.searchParams.get("access_token")).toBe("EAALtest123");
|
|
53
|
+
expect(url.searchParams.get("metric")).toBe("reach,profile_views");
|
|
54
|
+
expect(url.searchParams.get("period")).toBe("day");
|
|
55
|
+
});
|
|
56
|
+
it("POST sends JSON body with correct Content-Type", async () => {
|
|
57
|
+
const fetchMock = mockFetchOk({ id: "new_media_id" });
|
|
58
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
59
|
+
const client = new InstagramClient(creds);
|
|
60
|
+
await client.post("/17841400000000000/media", {
|
|
61
|
+
image_url: "https://example.com/test.jpg",
|
|
62
|
+
caption: "hello",
|
|
63
|
+
});
|
|
64
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
65
|
+
expect(init?.method).toBe("POST");
|
|
66
|
+
const headers = init?.headers;
|
|
67
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
68
|
+
expect(JSON.parse(init?.body)).toEqual({
|
|
69
|
+
image_url: "https://example.com/test.jpg",
|
|
70
|
+
caption: "hello",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
it("POST includes access_token in query even when body is present", async () => {
|
|
74
|
+
const fetchMock = mockFetchOk({ id: "x" });
|
|
75
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
76
|
+
const client = new InstagramClient(creds);
|
|
77
|
+
await client.post("/17841400000000000/media_publish", {
|
|
78
|
+
creation_id: "abc",
|
|
79
|
+
});
|
|
80
|
+
const url = fetchMock.mock.calls[0][0];
|
|
81
|
+
expect(url.searchParams.get("access_token")).toBe("EAALtest123");
|
|
82
|
+
});
|
|
83
|
+
it("DELETE sends DELETE method", async () => {
|
|
84
|
+
const fetchMock = mockFetchOk({ success: true });
|
|
85
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
86
|
+
const client = new InstagramClient(creds);
|
|
87
|
+
await client.delete("/comment_id_123");
|
|
88
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
89
|
+
expect(init?.method).toBe("DELETE");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe("InstagramClient — error handling", () => {
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
});
|
|
96
|
+
it("parses Graph API error into InstagramApiError with status/code/subcode", async () => {
|
|
97
|
+
vi.stubGlobal("fetch", mockFetchError(400, {
|
|
98
|
+
error: {
|
|
99
|
+
message: "Invalid OAuth access token",
|
|
100
|
+
type: "OAuthException",
|
|
101
|
+
code: 190,
|
|
102
|
+
error_subcode: 460,
|
|
103
|
+
fbtrace_id: "abc123",
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
const client = new InstagramClient(creds);
|
|
107
|
+
await expect(client.get("/me")).rejects.toMatchObject({
|
|
108
|
+
name: "InstagramApiError",
|
|
109
|
+
status: 400,
|
|
110
|
+
code: 190,
|
|
111
|
+
errorSubcode: 460,
|
|
112
|
+
errorType: "OAuthException",
|
|
113
|
+
message: "Invalid OAuth access token",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
it("wraps non-JSON HTML error responses in InstagramApiError", async () => {
|
|
117
|
+
const fetchMock = vi.fn(async () => new Response("<html>502 Bad Gateway</html>", {
|
|
118
|
+
status: 502,
|
|
119
|
+
headers: { "content-type": "text/html" },
|
|
120
|
+
}));
|
|
121
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
122
|
+
const client = new InstagramClient(creds);
|
|
123
|
+
await expect(client.get("/me")).rejects.toMatchObject({
|
|
124
|
+
status: 502,
|
|
125
|
+
errorType: "ParseError",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it("passes through Retry-After header as retryAfter", async () => {
|
|
129
|
+
vi.stubGlobal("fetch", mockFetchError(429, {
|
|
130
|
+
error: {
|
|
131
|
+
message: "Rate limited",
|
|
132
|
+
type: "OAuthException",
|
|
133
|
+
code: 4,
|
|
134
|
+
},
|
|
135
|
+
}, { "Retry-After": "90" }));
|
|
136
|
+
const client = new InstagramClient(creds);
|
|
137
|
+
try {
|
|
138
|
+
await client.get("/me");
|
|
139
|
+
expect.fail("should have thrown");
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
expect(e).toBeInstanceOf(InstagramApiError);
|
|
143
|
+
expect(e.retryAfter).toBe(90);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
it("wraps non-GraphAPI JSON error shapes in UnknownError", async () => {
|
|
147
|
+
vi.stubGlobal("fetch", mockFetchError(500, { something: "weird" }));
|
|
148
|
+
const client = new InstagramClient(creds);
|
|
149
|
+
await expect(client.get("/me")).rejects.toMatchObject({
|
|
150
|
+
status: 500,
|
|
151
|
+
errorType: "UnknownError",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe("createClient — caching", () => {
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
// Clear module state between tests by re-requiring isn't straightforward
|
|
158
|
+
// in ESM. Instead we rely on the fact that each cache key includes the
|
|
159
|
+
// full token+account pair — using unique tokens per test keeps them
|
|
160
|
+
// isolated.
|
|
161
|
+
});
|
|
162
|
+
it("returns the same instance for identical credentials", () => {
|
|
163
|
+
const a = createClient({ accessToken: "tok_same", accountId: "acc_1" });
|
|
164
|
+
const b = createClient({ accessToken: "tok_same", accountId: "acc_1" });
|
|
165
|
+
expect(a).toBe(b);
|
|
166
|
+
});
|
|
167
|
+
it("returns different instances for different tokens", () => {
|
|
168
|
+
const a = createClient({ accessToken: "tok_A_unique", accountId: "acc_x" });
|
|
169
|
+
const b = createClient({ accessToken: "tok_B_unique", accountId: "acc_x" });
|
|
170
|
+
expect(a).not.toBe(b);
|
|
171
|
+
});
|
|
172
|
+
it("returns different instances for different account IDs", () => {
|
|
173
|
+
const a = createClient({ accessToken: "tok_shared", accountId: "acc_1_u" });
|
|
174
|
+
const b = createClient({ accessToken: "tok_shared", accountId: "acc_2_u" });
|
|
175
|
+
expect(a).not.toBe(b);
|
|
176
|
+
});
|
|
177
|
+
it("returned clients carry the provided creds", () => {
|
|
178
|
+
const client = createClient({
|
|
179
|
+
accessToken: "tok_readback",
|
|
180
|
+
accountId: "acc_readback",
|
|
181
|
+
});
|
|
182
|
+
expect(client.accessToken).toBe("tok_readback");
|
|
183
|
+
expect(client.accountId).toBe("acc_readback");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("InstagramApiError — shape", () => {
|
|
187
|
+
it("carries all graph-api error fields", () => {
|
|
188
|
+
const err = new InstagramApiError(400, {
|
|
189
|
+
message: "boom",
|
|
190
|
+
type: "OAuthException",
|
|
191
|
+
code: 100,
|
|
192
|
+
error_subcode: 2207052,
|
|
193
|
+
}, 30);
|
|
194
|
+
expect(err).toBeInstanceOf(Error);
|
|
195
|
+
expect(err.name).toBe("InstagramApiError");
|
|
196
|
+
expect(err.status).toBe(400);
|
|
197
|
+
expect(err.code).toBe(100);
|
|
198
|
+
expect(err.errorSubcode).toBe(2207052);
|
|
199
|
+
expect(err.errorType).toBe("OAuthException");
|
|
200
|
+
expect(err.retryAfter).toBe(30);
|
|
201
|
+
expect(err.message).toBe("boom");
|
|
202
|
+
});
|
|
203
|
+
it("allows undefined subcode and retryAfter", () => {
|
|
204
|
+
const err = new InstagramApiError(500, {
|
|
205
|
+
message: "server error",
|
|
206
|
+
type: "InternalError",
|
|
207
|
+
code: 1,
|
|
208
|
+
});
|
|
209
|
+
expect(err.errorSubcode).toBeUndefined();
|
|
210
|
+
expect(err.retryAfter).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error mapping helpers — pure functions only, no IO.
|
|
3
|
+
*
|
|
4
|
+
* extractApiDetail() formats an InstagramApiError for human/agent consumption.
|
|
5
|
+
* suggestAction() maps (statusCode, detail) → an AGENT_ACTION hint string.
|
|
6
|
+
*
|
|
7
|
+
* These functions are pure and heavily unit-tested in errors.test.ts.
|
|
8
|
+
* Every hint string is a regression guard for a real bug discovered during
|
|
9
|
+
* live testing against the Instagram Graph API.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractApiDetail(e: unknown): string | undefined;
|
|
12
|
+
export declare function suggestAction(toolName: string, statusCode: number | undefined, detail: string | undefined, errorMsg?: string): string | undefined;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error mapping helpers — pure functions only, no IO.
|
|
3
|
+
*
|
|
4
|
+
* extractApiDetail() formats an InstagramApiError for human/agent consumption.
|
|
5
|
+
* suggestAction() maps (statusCode, detail) → an AGENT_ACTION hint string.
|
|
6
|
+
*
|
|
7
|
+
* These functions are pure and heavily unit-tested in errors.test.ts.
|
|
8
|
+
* Every hint string is a regression guard for a real bug discovered during
|
|
9
|
+
* live testing against the Instagram Graph API.
|
|
10
|
+
*/
|
|
11
|
+
import { InstagramApiError } from "./client.js";
|
|
12
|
+
export function extractApiDetail(e) {
|
|
13
|
+
if (e instanceof InstagramApiError) {
|
|
14
|
+
const sub = e.errorSubcode ? ` (subcode: ${e.errorSubcode})` : "";
|
|
15
|
+
return `${e.errorType}: ${e.message}${sub}`;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
export function suggestAction(toolName, statusCode, detail, errorMsg) {
|
|
20
|
+
const d = (detail || errorMsg || "").toLowerCase();
|
|
21
|
+
// Network-level errors
|
|
22
|
+
if (statusCode === undefined) {
|
|
23
|
+
if (d.includes("enotfound") || d.includes("dns"))
|
|
24
|
+
return "DNS_FAILURE: Cannot resolve graph.facebook.com. Check your internet connection.";
|
|
25
|
+
if (d.includes("timeout") || d.includes("abort"))
|
|
26
|
+
return "TIMEOUT: Request to Instagram timed out. Check your connection and retry.";
|
|
27
|
+
if (d.includes("econnrefused") || d.includes("econnreset"))
|
|
28
|
+
return "CONNECTION_FAILED: Cannot connect to Instagram. The service may be down. Retry in 30s.";
|
|
29
|
+
if (d.includes("fetch failed"))
|
|
30
|
+
return "NETWORK_ERROR: Network request failed. Check your internet connection and retry.";
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
// Token-invalidity errors can come back as 400 with an OAuthException.
|
|
34
|
+
// Match only explicit token-failure phrasing — NOT the bare word "oauth",
|
|
35
|
+
// because OAuthException is the generic error type for all Graph API failures.
|
|
36
|
+
if (d.includes("invalid oauth access token") ||
|
|
37
|
+
d.includes("access token has expired") ||
|
|
38
|
+
d.includes("session has expired") ||
|
|
39
|
+
d.includes("access token is invalid") ||
|
|
40
|
+
d.includes("cannot parse access token") ||
|
|
41
|
+
d.includes("malformed access token")) {
|
|
42
|
+
return "AUTH_FAILED: Access token is invalid or expired. Generate a new long-lived token via the Facebook Developer Console.";
|
|
43
|
+
}
|
|
44
|
+
switch (statusCode) {
|
|
45
|
+
case 400:
|
|
46
|
+
if ((d.includes("invalid") && d.includes("media")) ||
|
|
47
|
+
d.includes("photo or video can be accepted") ||
|
|
48
|
+
d.includes("2207052") ||
|
|
49
|
+
d.includes("2207027"))
|
|
50
|
+
return "INVALID_MEDIA: The media URL is invalid or inaccessible. Ensure the URL is publicly accessible, returns image/jpeg or image/png content-type, is not behind a redirect, and is not rate-limited by the host.";
|
|
51
|
+
if (d.includes("caption") || d.includes("too long"))
|
|
52
|
+
return "CAPTION_TOO_LONG: Caption exceeds 2200 characters. Shorten it and retry.";
|
|
53
|
+
if (d.includes("children") || d.includes("carousel"))
|
|
54
|
+
return "INVALID_CAROUSEL: Carousel requires 2-10 items. Check item count and media URLs.";
|
|
55
|
+
if (d.includes("hashtag"))
|
|
56
|
+
return "INVALID_HASHTAG: Hashtag search is limited to 30 unique hashtags per 7-day rolling window.";
|
|
57
|
+
return "INVALID_REQUEST: Check the error message and fix the input parameters.";
|
|
58
|
+
case 401:
|
|
59
|
+
return "AUTH_FAILED: Access token is invalid or expired. Generate a new long-lived token via the Facebook Developer Console.";
|
|
60
|
+
case 403:
|
|
61
|
+
if (d.includes("permission"))
|
|
62
|
+
return "PERMISSION_DENIED: Your token lacks the required permission. Check token permissions in Facebook Developer Console.";
|
|
63
|
+
if (d.includes("not approved") || d.includes("app not"))
|
|
64
|
+
return "APP_NOT_APPROVED: Your Facebook app needs approval for this permission. Submit for review in the App Dashboard.";
|
|
65
|
+
if (d.includes("business"))
|
|
66
|
+
return "BUSINESS_ACCOUNT_REQUIRED: This feature requires an Instagram Business or Creator account linked to a Facebook Page.";
|
|
67
|
+
return "FORBIDDEN: Instagram rejected this action. Check the error message for details.";
|
|
68
|
+
case 404: {
|
|
69
|
+
const t = toolName.toLowerCase();
|
|
70
|
+
if (t.includes("comment"))
|
|
71
|
+
return "COMMENT_NOT_FOUND: This comment may have been deleted. Skip it and move on.";
|
|
72
|
+
if (t.includes("post") || t.includes("insight"))
|
|
73
|
+
return "MEDIA_NOT_FOUND: This post may have been deleted or the ID is invalid. Skip it.";
|
|
74
|
+
if (t.includes("story"))
|
|
75
|
+
return "STORY_NOT_FOUND: Stories expire after 24 hours. This story is no longer available.";
|
|
76
|
+
return "NOT_FOUND: The requested resource does not exist. It may have been deleted.";
|
|
77
|
+
}
|
|
78
|
+
case 429:
|
|
79
|
+
return "RATE_LIMITED: Instagram rate limit hit after automatic retries. Wait 60s and retry, or switch to a different task.";
|
|
80
|
+
case 500:
|
|
81
|
+
case 502:
|
|
82
|
+
case 503:
|
|
83
|
+
return "SERVER_ERROR: Instagram is having issues. Wait 30s and retry once.";
|
|
84
|
+
default:
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for error mapping helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every test case here is a regression guard for a specific bug discovered
|
|
5
|
+
* during live testing against the real Instagram Graph API. Changing any of
|
|
6
|
+
* the asserted strings without updating the tool's error-handling docs is
|
|
7
|
+
* a contract break for agents consuming these responses.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for error mapping helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every test case here is a regression guard for a specific bug discovered
|
|
5
|
+
* during live testing against the real Instagram Graph API. Changing any of
|
|
6
|
+
* the asserted strings without updating the tool's error-handling docs is
|
|
7
|
+
* a contract break for agents consuming these responses.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { extractApiDetail, suggestAction } from "./errors.js";
|
|
11
|
+
import { InstagramApiError } from "./client.js";
|
|
12
|
+
describe("extractApiDetail", () => {
|
|
13
|
+
it("returns undefined for non-InstagramApiError values", () => {
|
|
14
|
+
expect(extractApiDetail(new Error("boom"))).toBeUndefined();
|
|
15
|
+
expect(extractApiDetail("string error")).toBeUndefined();
|
|
16
|
+
expect(extractApiDetail(null)).toBeUndefined();
|
|
17
|
+
expect(extractApiDetail(undefined)).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
it("formats InstagramApiError without subcode", () => {
|
|
20
|
+
const err = new InstagramApiError(400, {
|
|
21
|
+
message: "Invalid OAuth access token - Cannot parse access token",
|
|
22
|
+
type: "OAuthException",
|
|
23
|
+
code: 190,
|
|
24
|
+
});
|
|
25
|
+
expect(extractApiDetail(err)).toBe("OAuthException: Invalid OAuth access token - Cannot parse access token");
|
|
26
|
+
});
|
|
27
|
+
it("formats InstagramApiError with subcode", () => {
|
|
28
|
+
const err = new InstagramApiError(400, {
|
|
29
|
+
message: "Media ID is not available",
|
|
30
|
+
type: "OAuthException",
|
|
31
|
+
code: 100,
|
|
32
|
+
error_subcode: 2207027,
|
|
33
|
+
});
|
|
34
|
+
expect(extractApiDetail(err)).toBe("OAuthException: Media ID is not available (subcode: 2207027)");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("suggestAction — network errors (no statusCode)", () => {
|
|
38
|
+
it("ENOTFOUND → DNS_FAILURE", () => {
|
|
39
|
+
const r = suggestAction("ig_get_account_insights", undefined, undefined, "getaddrinfo ENOTFOUND graph.facebook.com");
|
|
40
|
+
expect(r).toMatch(/^DNS_FAILURE:/);
|
|
41
|
+
});
|
|
42
|
+
it("timeout → TIMEOUT", () => {
|
|
43
|
+
expect(suggestAction("ig_get_comments", undefined, undefined, "The operation was aborted due to timeout")).toMatch(/^TIMEOUT:/);
|
|
44
|
+
});
|
|
45
|
+
it("ECONNREFUSED → CONNECTION_FAILED", () => {
|
|
46
|
+
expect(suggestAction("ig_get_comments", undefined, undefined, "connect ECONNREFUSED 31.13.84.4:443")).toMatch(/^CONNECTION_FAILED:/);
|
|
47
|
+
});
|
|
48
|
+
it("fetch failed → NETWORK_ERROR", () => {
|
|
49
|
+
expect(suggestAction("ig_get_comments", undefined, undefined, "fetch failed")).toMatch(/^NETWORK_ERROR:/);
|
|
50
|
+
});
|
|
51
|
+
it("unknown network error → undefined", () => {
|
|
52
|
+
expect(suggestAction("ig_get_comments", undefined, undefined, "something weird")).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("suggestAction — token errors (high priority for Bug #6)", () => {
|
|
56
|
+
it("'Invalid OAuth access token' → AUTH_FAILED", () => {
|
|
57
|
+
const r = suggestAction("ig_get_account_insights", 400, "OAuthException: Invalid OAuth access token - Cannot parse access token");
|
|
58
|
+
expect(r).toMatch(/^AUTH_FAILED:/);
|
|
59
|
+
});
|
|
60
|
+
it("'Cannot parse access token' → AUTH_FAILED", () => {
|
|
61
|
+
expect(suggestAction("any", 400, "Cannot parse access token")).toMatch(/^AUTH_FAILED:/);
|
|
62
|
+
});
|
|
63
|
+
it("'access token has expired' → AUTH_FAILED", () => {
|
|
64
|
+
expect(suggestAction("any", 400, "Error validating access token: access token has expired")).toMatch(/^AUTH_FAILED:/);
|
|
65
|
+
});
|
|
66
|
+
it("'session has expired' → AUTH_FAILED", () => {
|
|
67
|
+
expect(suggestAction("any", 400, "The session has expired on Tuesday")).toMatch(/^AUTH_FAILED:/);
|
|
68
|
+
});
|
|
69
|
+
it("'malformed access token' → AUTH_FAILED", () => {
|
|
70
|
+
expect(suggestAction("any", 400, "Malformed access token")).toMatch(/^AUTH_FAILED:/);
|
|
71
|
+
});
|
|
72
|
+
// REGRESSION: generic OAuthException for metric errors must NOT be AUTH_FAILED
|
|
73
|
+
it("metric error mentioning OAuthException is NOT AUTH_FAILED", () => {
|
|
74
|
+
const metricErr = "OAuthException: (#100) metric[2] must be one of the following values: reach, follower_count, profile_views";
|
|
75
|
+
const r = suggestAction("ig_get_stories_insights", 400, metricErr);
|
|
76
|
+
expect(r).not.toMatch(/^AUTH_FAILED:/);
|
|
77
|
+
expect(r).toMatch(/^INVALID_REQUEST:/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe("suggestAction — 400 media errors (regression guard for Bug #9)", () => {
|
|
81
|
+
it("'photo or video can be accepted' → INVALID_MEDIA", () => {
|
|
82
|
+
const r = suggestAction("ig_publish_photo", 400, "OAuthException: Only photo or video can be accepted as media type. (subcode: 2207052)");
|
|
83
|
+
expect(r).toMatch(/^INVALID_MEDIA:/);
|
|
84
|
+
});
|
|
85
|
+
it("subcode 2207052 → INVALID_MEDIA", () => {
|
|
86
|
+
expect(suggestAction("ig_publish_carousel", 400, "Some error text (subcode: 2207052)")).toMatch(/^INVALID_MEDIA:/);
|
|
87
|
+
});
|
|
88
|
+
it("subcode 2207027 → INVALID_MEDIA", () => {
|
|
89
|
+
expect(suggestAction("ig_publish_photo", 400, "Media ID is not available (subcode: 2207027)")).toMatch(/^INVALID_MEDIA:/);
|
|
90
|
+
});
|
|
91
|
+
it("'invalid media URL' → INVALID_MEDIA", () => {
|
|
92
|
+
expect(suggestAction("ig_publish_photo", 400, "Invalid media URL provided")).toMatch(/^INVALID_MEDIA:/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("suggestAction — other 400 branches", () => {
|
|
96
|
+
it("caption mention → CAPTION_TOO_LONG", () => {
|
|
97
|
+
expect(suggestAction("ig_publish_photo", 400, "Caption is too long")).toMatch(/^CAPTION_TOO_LONG:/);
|
|
98
|
+
});
|
|
99
|
+
it("'too long' → CAPTION_TOO_LONG", () => {
|
|
100
|
+
expect(suggestAction("ig_publish_photo", 400, "Value too long")).toMatch(/^CAPTION_TOO_LONG:/);
|
|
101
|
+
});
|
|
102
|
+
it("carousel children error → INVALID_CAROUSEL", () => {
|
|
103
|
+
expect(suggestAction("ig_publish_carousel", 400, "Carousel children must be between 2 and 10")).toMatch(/^INVALID_CAROUSEL:/);
|
|
104
|
+
});
|
|
105
|
+
it("hashtag error → INVALID_HASHTAG", () => {
|
|
106
|
+
expect(suggestAction("ig_get_hashtag_search", 400, "Hashtag quota exceeded")).toMatch(/^INVALID_HASHTAG:/);
|
|
107
|
+
});
|
|
108
|
+
it("unmatched 400 → INVALID_REQUEST", () => {
|
|
109
|
+
expect(suggestAction("any", 400, "Unknown bad request reason")).toMatch(/^INVALID_REQUEST:/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe("suggestAction — 401/403/404", () => {
|
|
113
|
+
it("401 → AUTH_FAILED", () => {
|
|
114
|
+
expect(suggestAction("any", 401, "anything")).toMatch(/^AUTH_FAILED:/);
|
|
115
|
+
});
|
|
116
|
+
it("403 with 'permission' → PERMISSION_DENIED", () => {
|
|
117
|
+
expect(suggestAction("any", 403, "You lack the required permission")).toMatch(/^PERMISSION_DENIED:/);
|
|
118
|
+
});
|
|
119
|
+
it("403 with 'not approved' → APP_NOT_APPROVED", () => {
|
|
120
|
+
expect(suggestAction("any", 403, "This feature is not approved for your app")).toMatch(/^APP_NOT_APPROVED:/);
|
|
121
|
+
});
|
|
122
|
+
it("403 with 'business' → BUSINESS_ACCOUNT_REQUIRED", () => {
|
|
123
|
+
expect(suggestAction("any", 403, "A business account is required")).toMatch(/^BUSINESS_ACCOUNT_REQUIRED:/);
|
|
124
|
+
});
|
|
125
|
+
it("403 unmatched → FORBIDDEN", () => {
|
|
126
|
+
expect(suggestAction("any", 403, "generic denial")).toMatch(/^FORBIDDEN:/);
|
|
127
|
+
});
|
|
128
|
+
it("404 comment tool → COMMENT_NOT_FOUND", () => {
|
|
129
|
+
expect(suggestAction("ig_delete_comment", 404, "not found")).toMatch(/^COMMENT_NOT_FOUND:/);
|
|
130
|
+
});
|
|
131
|
+
it("404 insight tool → MEDIA_NOT_FOUND", () => {
|
|
132
|
+
expect(suggestAction("ig_get_post_insights", 404, "not found")).toMatch(/^MEDIA_NOT_FOUND:/);
|
|
133
|
+
});
|
|
134
|
+
it("404 story tool → STORY_NOT_FOUND", () => {
|
|
135
|
+
// Tool name must include 'story' AND NOT 'insight' / 'post' / 'comment'
|
|
136
|
+
// (those are checked first). ig_story_publish is a hypothetical example.
|
|
137
|
+
expect(suggestAction("ig_story_fetch", 404, "not found")).toMatch(/^STORY_NOT_FOUND:/);
|
|
138
|
+
});
|
|
139
|
+
// Real-world: ig_get_stories_insights contains both 'story' and 'insight',
|
|
140
|
+
// and 'insight' is checked first → MEDIA_NOT_FOUND (documented quirk).
|
|
141
|
+
it("404 ig_get_stories_insights → MEDIA_NOT_FOUND (insight wins)", () => {
|
|
142
|
+
expect(suggestAction("ig_get_stories_insights", 404, "not found")).toMatch(/^MEDIA_NOT_FOUND:/);
|
|
143
|
+
});
|
|
144
|
+
it("404 generic tool → NOT_FOUND", () => {
|
|
145
|
+
expect(suggestAction("ig_misc_tool", 404, "not found")).toMatch(/^NOT_FOUND:/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe("suggestAction — 429 and 5xx", () => {
|
|
149
|
+
it("429 → RATE_LIMITED", () => {
|
|
150
|
+
expect(suggestAction("any", 429, "rate limited")).toMatch(/^RATE_LIMITED:/);
|
|
151
|
+
});
|
|
152
|
+
it("500 → SERVER_ERROR", () => {
|
|
153
|
+
expect(suggestAction("any", 500, "internal server error")).toMatch(/^SERVER_ERROR:/);
|
|
154
|
+
});
|
|
155
|
+
it("502 → SERVER_ERROR", () => {
|
|
156
|
+
expect(suggestAction("any", 502, "bad gateway")).toMatch(/^SERVER_ERROR:/);
|
|
157
|
+
});
|
|
158
|
+
it("503 → SERVER_ERROR", () => {
|
|
159
|
+
expect(suggestAction("any", 503, "service unavailable")).toMatch(/^SERVER_ERROR:/);
|
|
160
|
+
});
|
|
161
|
+
it("unknown status → undefined", () => {
|
|
162
|
+
expect(suggestAction("any", 418, "I'm a teapot")).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the #1995 first-comment partial-success contract (#1931).
|
|
3
|
+
*
|
|
4
|
+
* The whole point of first-comment publishing is "no lying success": if the
|
|
5
|
+
* media publishes but the follow-up comment fails, the handler must surface a
|
|
6
|
+
* partial failure — never a clean success. postFirstComment is the isolated
|
|
7
|
+
* step the three publish handlers (photo/carousel/reel) all delegate to, so a
|
|
8
|
+
* test here guards every IG publish path against the silent-swallow regression.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|