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.
Files changed (59) hide show
  1. package/.env.example +2 -0
  2. package/.github/dependabot.yml +50 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/release.yml +200 -0
  5. package/CONTRIBUTING.md +38 -0
  6. package/LICENSE +21 -0
  7. package/Makefile +16 -0
  8. package/README.md +141 -0
  9. package/dist/client.d.ts +57 -0
  10. package/dist/client.js +140 -0
  11. package/dist/client.test.d.ts +10 -0
  12. package/dist/client.test.js +212 -0
  13. package/dist/errors.d.ts +12 -0
  14. package/dist/errors.js +87 -0
  15. package/dist/errors.test.d.ts +9 -0
  16. package/dist/errors.test.js +164 -0
  17. package/dist/first-comment.test.d.ts +10 -0
  18. package/dist/first-comment.test.js +56 -0
  19. package/dist/handlers.test.d.ts +23 -0
  20. package/dist/handlers.test.js +355 -0
  21. package/dist/index.d.ts +49 -0
  22. package/dist/index.js +627 -0
  23. package/dist/lib/index.d.ts +6 -0
  24. package/dist/lib/index.js +5 -0
  25. package/dist/lib/insights.d.ts +55 -0
  26. package/dist/lib/insights.js +59 -0
  27. package/dist/rate-limiter.d.ts +72 -0
  28. package/dist/rate-limiter.js +219 -0
  29. package/dist/rate-limiter.test.d.ts +1 -0
  30. package/dist/rate-limiter.test.js +153 -0
  31. package/dist/response.d.ts +24 -0
  32. package/dist/response.js +35 -0
  33. package/dist/response.test.d.ts +1 -0
  34. package/dist/response.test.js +71 -0
  35. package/dist/sanitize.d.ts +17 -0
  36. package/dist/sanitize.js +27 -0
  37. package/dist/sanitize.test.d.ts +1 -0
  38. package/dist/sanitize.test.js +43 -0
  39. package/dist/tools.test.d.ts +14 -0
  40. package/dist/tools.test.js +188 -0
  41. package/package.json +32 -0
  42. package/src/client.test.ts +285 -0
  43. package/src/client.ts +204 -0
  44. package/src/errors.test.ts +299 -0
  45. package/src/errors.ts +108 -0
  46. package/src/first-comment.test.ts +75 -0
  47. package/src/handlers.test.ts +460 -0
  48. package/src/index.ts +960 -0
  49. package/src/lib/index.ts +6 -0
  50. package/src/lib/insights.ts +182 -0
  51. package/src/rate-limiter.test.ts +185 -0
  52. package/src/rate-limiter.ts +257 -0
  53. package/src/response.test.ts +80 -0
  54. package/src/response.ts +43 -0
  55. package/src/sanitize.test.ts +52 -0
  56. package/src/sanitize.ts +35 -0
  57. package/src/tools.test.ts +251 -0
  58. package/tsconfig.json +15 -0
  59. package/vitest.config.ts +10 -0
package/src/client.ts ADDED
@@ -0,0 +1,204 @@
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
+
9
+ import { createHash } from "node:crypto";
10
+
11
+ const GRAPH_API_BASE = "https://graph.facebook.com/v21.0";
12
+ const CLIENT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
13
+
14
+ export interface Credentials {
15
+ accessToken: string;
16
+ accountId: string;
17
+ }
18
+
19
+ interface CachedClient {
20
+ client: InstagramClient;
21
+ createdAt: number;
22
+ }
23
+
24
+ const clientCache = new Map<string, CachedClient>();
25
+
26
+ function credentialHash(creds: Credentials): string {
27
+ const raw = `${creds.accessToken}:${creds.accountId}`;
28
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16);
29
+ }
30
+
31
+ /**
32
+ * Get or create a cached Instagram client.
33
+ */
34
+ export function createClient(creds: Credentials): InstagramClient {
35
+ const key = credentialHash(creds);
36
+ const now = Date.now();
37
+
38
+ // Evict stale entries on every call
39
+ for (const [k, v] of clientCache) {
40
+ if (now - v.createdAt >= CLIENT_TTL_MS) {
41
+ clientCache.delete(k);
42
+ }
43
+ }
44
+
45
+ const cached = clientCache.get(key);
46
+ if (cached) return cached.client;
47
+
48
+ const client = new InstagramClient(creds);
49
+ clientCache.set(key, { client, createdAt: now });
50
+ return client;
51
+ }
52
+
53
+ /**
54
+ * Graph API error format from Facebook/Instagram.
55
+ */
56
+ export interface GraphApiError {
57
+ error: {
58
+ message: string;
59
+ type: string;
60
+ code: number;
61
+ error_subcode?: number;
62
+ fbtrace_id?: string;
63
+ };
64
+ }
65
+
66
+ function isGraphApiError(body: unknown): body is GraphApiError {
67
+ return (
68
+ typeof body === "object" &&
69
+ body !== null &&
70
+ "error" in body &&
71
+ typeof (body as GraphApiError).error === "object" &&
72
+ typeof (body as GraphApiError).error.message === "string"
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Error thrown for Graph API failures. Carries structured error data
78
+ * for suggestAction() to inspect.
79
+ */
80
+ export class InstagramApiError extends Error {
81
+ readonly status: number;
82
+ readonly code: number;
83
+ readonly errorSubcode?: number;
84
+ readonly errorType: string;
85
+ readonly retryAfter?: number;
86
+
87
+ constructor(
88
+ status: number,
89
+ apiError: GraphApiError["error"],
90
+ retryAfter?: number,
91
+ ) {
92
+ super(apiError.message);
93
+ this.name = "InstagramApiError";
94
+ this.status = status;
95
+ this.code = apiError.code;
96
+ this.errorSubcode = apiError.error_subcode;
97
+ this.errorType = apiError.type;
98
+ this.retryAfter = retryAfter;
99
+ }
100
+ }
101
+
102
+ export class InstagramClient {
103
+ readonly accessToken: string;
104
+ readonly accountId: string;
105
+
106
+ constructor(creds: Credentials) {
107
+ this.accessToken = creds.accessToken;
108
+ this.accountId = creds.accountId;
109
+ }
110
+
111
+ /**
112
+ * Make a GET request to the Graph API.
113
+ */
114
+ async get<T = unknown>(
115
+ path: string,
116
+ params?: Record<string, string>,
117
+ ): Promise<T> {
118
+ const url = new URL(`${GRAPH_API_BASE}${path}`);
119
+ url.searchParams.set("access_token", this.accessToken);
120
+ if (params) {
121
+ for (const [key, value] of Object.entries(params)) {
122
+ url.searchParams.set(key, value);
123
+ }
124
+ }
125
+
126
+ const res = await fetch(url, {
127
+ signal: AbortSignal.timeout(30_000),
128
+ });
129
+
130
+ return this.handleResponse<T>(res);
131
+ }
132
+
133
+ /**
134
+ * Make a POST request to the Graph API.
135
+ */
136
+ async post<T = unknown>(
137
+ path: string,
138
+ body?: Record<string, unknown>,
139
+ ): Promise<T> {
140
+ const url = new URL(`${GRAPH_API_BASE}${path}`);
141
+ url.searchParams.set("access_token", this.accessToken);
142
+
143
+ const res = await fetch(url, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: body ? JSON.stringify(body) : undefined,
147
+ signal: AbortSignal.timeout(30_000),
148
+ });
149
+
150
+ return this.handleResponse<T>(res);
151
+ }
152
+
153
+ /**
154
+ * Make a DELETE request to the Graph API.
155
+ */
156
+ async delete<T = unknown>(path: string): Promise<T> {
157
+ const url = new URL(`${GRAPH_API_BASE}${path}`);
158
+ url.searchParams.set("access_token", this.accessToken);
159
+
160
+ const res = await fetch(url, {
161
+ method: "DELETE",
162
+ signal: AbortSignal.timeout(30_000),
163
+ });
164
+
165
+ return this.handleResponse<T>(res);
166
+ }
167
+
168
+ private async handleResponse<T>(res: Response): Promise<T> {
169
+ let body: unknown;
170
+ try {
171
+ body = await res.json();
172
+ } catch {
173
+ // Non-JSON response (HTML error page during outage, empty body, etc.)
174
+ throw new InstagramApiError(res.status, {
175
+ message: `HTTP ${res.status}: Response is not valid JSON (likely an outage or proxy error)`,
176
+ type: "ParseError",
177
+ code: res.status,
178
+ });
179
+ }
180
+
181
+ if (!res.ok) {
182
+ const retryAfterHeader = res.headers.get("Retry-After");
183
+ const seconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
184
+ const retryAfter = !isNaN(seconds) ? seconds : undefined;
185
+
186
+ if (isGraphApiError(body)) {
187
+ throw new InstagramApiError(res.status, body.error, retryAfter);
188
+ }
189
+
190
+ // Non-standard error response — wrap in a generic error
191
+ throw new InstagramApiError(
192
+ res.status,
193
+ {
194
+ message: `HTTP ${res.status}: ${JSON.stringify(body)}`,
195
+ type: "UnknownError",
196
+ code: res.status,
197
+ },
198
+ retryAfter,
199
+ );
200
+ }
201
+
202
+ return body as T;
203
+ }
204
+ }
@@ -0,0 +1,299 @@
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
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { extractApiDetail, suggestAction } from "./errors.js";
12
+ import { InstagramApiError } from "./client.js";
13
+
14
+ describe("extractApiDetail", () => {
15
+ it("returns undefined for non-InstagramApiError values", () => {
16
+ expect(extractApiDetail(new Error("boom"))).toBeUndefined();
17
+ expect(extractApiDetail("string error")).toBeUndefined();
18
+ expect(extractApiDetail(null)).toBeUndefined();
19
+ expect(extractApiDetail(undefined)).toBeUndefined();
20
+ });
21
+
22
+ it("formats InstagramApiError without subcode", () => {
23
+ const err = new InstagramApiError(400, {
24
+ message: "Invalid OAuth access token - Cannot parse access token",
25
+ type: "OAuthException",
26
+ code: 190,
27
+ });
28
+ expect(extractApiDetail(err)).toBe(
29
+ "OAuthException: Invalid OAuth access token - Cannot parse access token",
30
+ );
31
+ });
32
+
33
+ it("formats InstagramApiError with subcode", () => {
34
+ const err = new InstagramApiError(400, {
35
+ message: "Media ID is not available",
36
+ type: "OAuthException",
37
+ code: 100,
38
+ error_subcode: 2207027,
39
+ });
40
+ expect(extractApiDetail(err)).toBe(
41
+ "OAuthException: Media ID is not available (subcode: 2207027)",
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("suggestAction — network errors (no statusCode)", () => {
47
+ it("ENOTFOUND → DNS_FAILURE", () => {
48
+ const r = suggestAction(
49
+ "ig_get_account_insights",
50
+ undefined,
51
+ undefined,
52
+ "getaddrinfo ENOTFOUND graph.facebook.com",
53
+ );
54
+ expect(r).toMatch(/^DNS_FAILURE:/);
55
+ });
56
+
57
+ it("timeout → TIMEOUT", () => {
58
+ expect(
59
+ suggestAction(
60
+ "ig_get_comments",
61
+ undefined,
62
+ undefined,
63
+ "The operation was aborted due to timeout",
64
+ ),
65
+ ).toMatch(/^TIMEOUT:/);
66
+ });
67
+
68
+ it("ECONNREFUSED → CONNECTION_FAILED", () => {
69
+ expect(
70
+ suggestAction(
71
+ "ig_get_comments",
72
+ undefined,
73
+ undefined,
74
+ "connect ECONNREFUSED 31.13.84.4:443",
75
+ ),
76
+ ).toMatch(/^CONNECTION_FAILED:/);
77
+ });
78
+
79
+ it("fetch failed → NETWORK_ERROR", () => {
80
+ expect(
81
+ suggestAction("ig_get_comments", undefined, undefined, "fetch failed"),
82
+ ).toMatch(/^NETWORK_ERROR:/);
83
+ });
84
+
85
+ it("unknown network error → undefined", () => {
86
+ expect(
87
+ suggestAction("ig_get_comments", undefined, undefined, "something weird"),
88
+ ).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe("suggestAction — token errors (high priority for Bug #6)", () => {
93
+ it("'Invalid OAuth access token' → AUTH_FAILED", () => {
94
+ const r = suggestAction(
95
+ "ig_get_account_insights",
96
+ 400,
97
+ "OAuthException: Invalid OAuth access token - Cannot parse access token",
98
+ );
99
+ expect(r).toMatch(/^AUTH_FAILED:/);
100
+ });
101
+
102
+ it("'Cannot parse access token' → AUTH_FAILED", () => {
103
+ expect(suggestAction("any", 400, "Cannot parse access token")).toMatch(
104
+ /^AUTH_FAILED:/,
105
+ );
106
+ });
107
+
108
+ it("'access token has expired' → AUTH_FAILED", () => {
109
+ expect(
110
+ suggestAction(
111
+ "any",
112
+ 400,
113
+ "Error validating access token: access token has expired",
114
+ ),
115
+ ).toMatch(/^AUTH_FAILED:/);
116
+ });
117
+
118
+ it("'session has expired' → AUTH_FAILED", () => {
119
+ expect(
120
+ suggestAction("any", 400, "The session has expired on Tuesday"),
121
+ ).toMatch(/^AUTH_FAILED:/);
122
+ });
123
+
124
+ it("'malformed access token' → AUTH_FAILED", () => {
125
+ expect(suggestAction("any", 400, "Malformed access token")).toMatch(
126
+ /^AUTH_FAILED:/,
127
+ );
128
+ });
129
+
130
+ // REGRESSION: generic OAuthException for metric errors must NOT be AUTH_FAILED
131
+ it("metric error mentioning OAuthException is NOT AUTH_FAILED", () => {
132
+ const metricErr =
133
+ "OAuthException: (#100) metric[2] must be one of the following values: reach, follower_count, profile_views";
134
+ const r = suggestAction("ig_get_stories_insights", 400, metricErr);
135
+ expect(r).not.toMatch(/^AUTH_FAILED:/);
136
+ expect(r).toMatch(/^INVALID_REQUEST:/);
137
+ });
138
+ });
139
+
140
+ describe("suggestAction — 400 media errors (regression guard for Bug #9)", () => {
141
+ it("'photo or video can be accepted' → INVALID_MEDIA", () => {
142
+ const r = suggestAction(
143
+ "ig_publish_photo",
144
+ 400,
145
+ "OAuthException: Only photo or video can be accepted as media type. (subcode: 2207052)",
146
+ );
147
+ expect(r).toMatch(/^INVALID_MEDIA:/);
148
+ });
149
+
150
+ it("subcode 2207052 → INVALID_MEDIA", () => {
151
+ expect(
152
+ suggestAction(
153
+ "ig_publish_carousel",
154
+ 400,
155
+ "Some error text (subcode: 2207052)",
156
+ ),
157
+ ).toMatch(/^INVALID_MEDIA:/);
158
+ });
159
+
160
+ it("subcode 2207027 → INVALID_MEDIA", () => {
161
+ expect(
162
+ suggestAction(
163
+ "ig_publish_photo",
164
+ 400,
165
+ "Media ID is not available (subcode: 2207027)",
166
+ ),
167
+ ).toMatch(/^INVALID_MEDIA:/);
168
+ });
169
+
170
+ it("'invalid media URL' → INVALID_MEDIA", () => {
171
+ expect(
172
+ suggestAction("ig_publish_photo", 400, "Invalid media URL provided"),
173
+ ).toMatch(/^INVALID_MEDIA:/);
174
+ });
175
+ });
176
+
177
+ describe("suggestAction — other 400 branches", () => {
178
+ it("caption mention → CAPTION_TOO_LONG", () => {
179
+ expect(
180
+ suggestAction("ig_publish_photo", 400, "Caption is too long"),
181
+ ).toMatch(/^CAPTION_TOO_LONG:/);
182
+ });
183
+
184
+ it("'too long' → CAPTION_TOO_LONG", () => {
185
+ expect(suggestAction("ig_publish_photo", 400, "Value too long")).toMatch(
186
+ /^CAPTION_TOO_LONG:/,
187
+ );
188
+ });
189
+
190
+ it("carousel children error → INVALID_CAROUSEL", () => {
191
+ expect(
192
+ suggestAction(
193
+ "ig_publish_carousel",
194
+ 400,
195
+ "Carousel children must be between 2 and 10",
196
+ ),
197
+ ).toMatch(/^INVALID_CAROUSEL:/);
198
+ });
199
+
200
+ it("hashtag error → INVALID_HASHTAG", () => {
201
+ expect(
202
+ suggestAction("ig_get_hashtag_search", 400, "Hashtag quota exceeded"),
203
+ ).toMatch(/^INVALID_HASHTAG:/);
204
+ });
205
+
206
+ it("unmatched 400 → INVALID_REQUEST", () => {
207
+ expect(suggestAction("any", 400, "Unknown bad request reason")).toMatch(
208
+ /^INVALID_REQUEST:/,
209
+ );
210
+ });
211
+ });
212
+
213
+ describe("suggestAction — 401/403/404", () => {
214
+ it("401 → AUTH_FAILED", () => {
215
+ expect(suggestAction("any", 401, "anything")).toMatch(/^AUTH_FAILED:/);
216
+ });
217
+
218
+ it("403 with 'permission' → PERMISSION_DENIED", () => {
219
+ expect(
220
+ suggestAction("any", 403, "You lack the required permission"),
221
+ ).toMatch(/^PERMISSION_DENIED:/);
222
+ });
223
+
224
+ it("403 with 'not approved' → APP_NOT_APPROVED", () => {
225
+ expect(
226
+ suggestAction("any", 403, "This feature is not approved for your app"),
227
+ ).toMatch(/^APP_NOT_APPROVED:/);
228
+ });
229
+
230
+ it("403 with 'business' → BUSINESS_ACCOUNT_REQUIRED", () => {
231
+ expect(suggestAction("any", 403, "A business account is required")).toMatch(
232
+ /^BUSINESS_ACCOUNT_REQUIRED:/,
233
+ );
234
+ });
235
+
236
+ it("403 unmatched → FORBIDDEN", () => {
237
+ expect(suggestAction("any", 403, "generic denial")).toMatch(/^FORBIDDEN:/);
238
+ });
239
+
240
+ it("404 comment tool → COMMENT_NOT_FOUND", () => {
241
+ expect(suggestAction("ig_delete_comment", 404, "not found")).toMatch(
242
+ /^COMMENT_NOT_FOUND:/,
243
+ );
244
+ });
245
+
246
+ it("404 insight tool → MEDIA_NOT_FOUND", () => {
247
+ expect(suggestAction("ig_get_post_insights", 404, "not found")).toMatch(
248
+ /^MEDIA_NOT_FOUND:/,
249
+ );
250
+ });
251
+
252
+ it("404 story tool → STORY_NOT_FOUND", () => {
253
+ // Tool name must include 'story' AND NOT 'insight' / 'post' / 'comment'
254
+ // (those are checked first). ig_story_publish is a hypothetical example.
255
+ expect(suggestAction("ig_story_fetch", 404, "not found")).toMatch(
256
+ /^STORY_NOT_FOUND:/,
257
+ );
258
+ });
259
+
260
+ // Real-world: ig_get_stories_insights contains both 'story' and 'insight',
261
+ // and 'insight' is checked first → MEDIA_NOT_FOUND (documented quirk).
262
+ it("404 ig_get_stories_insights → MEDIA_NOT_FOUND (insight wins)", () => {
263
+ expect(suggestAction("ig_get_stories_insights", 404, "not found")).toMatch(
264
+ /^MEDIA_NOT_FOUND:/,
265
+ );
266
+ });
267
+
268
+ it("404 generic tool → NOT_FOUND", () => {
269
+ expect(suggestAction("ig_misc_tool", 404, "not found")).toMatch(
270
+ /^NOT_FOUND:/,
271
+ );
272
+ });
273
+ });
274
+
275
+ describe("suggestAction — 429 and 5xx", () => {
276
+ it("429 → RATE_LIMITED", () => {
277
+ expect(suggestAction("any", 429, "rate limited")).toMatch(/^RATE_LIMITED:/);
278
+ });
279
+
280
+ it("500 → SERVER_ERROR", () => {
281
+ expect(suggestAction("any", 500, "internal server error")).toMatch(
282
+ /^SERVER_ERROR:/,
283
+ );
284
+ });
285
+
286
+ it("502 → SERVER_ERROR", () => {
287
+ expect(suggestAction("any", 502, "bad gateway")).toMatch(/^SERVER_ERROR:/);
288
+ });
289
+
290
+ it("503 → SERVER_ERROR", () => {
291
+ expect(suggestAction("any", 503, "service unavailable")).toMatch(
292
+ /^SERVER_ERROR:/,
293
+ );
294
+ });
295
+
296
+ it("unknown status → undefined", () => {
297
+ expect(suggestAction("any", 418, "I'm a teapot")).toBeUndefined();
298
+ });
299
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,108 @@
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
+
12
+ import { InstagramApiError } from "./client.js";
13
+
14
+ export function extractApiDetail(e: unknown): string | undefined {
15
+ if (e instanceof InstagramApiError) {
16
+ const sub = e.errorSubcode ? ` (subcode: ${e.errorSubcode})` : "";
17
+ return `${e.errorType}: ${e.message}${sub}`;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ export function suggestAction(
23
+ toolName: string,
24
+ statusCode: number | undefined,
25
+ detail: string | undefined,
26
+ errorMsg?: string,
27
+ ): string | undefined {
28
+ const d = (detail || errorMsg || "").toLowerCase();
29
+
30
+ // Network-level errors
31
+ if (statusCode === undefined) {
32
+ if (d.includes("enotfound") || d.includes("dns"))
33
+ return "DNS_FAILURE: Cannot resolve graph.facebook.com. Check your internet connection.";
34
+ if (d.includes("timeout") || d.includes("abort"))
35
+ return "TIMEOUT: Request to Instagram timed out. Check your connection and retry.";
36
+ if (d.includes("econnrefused") || d.includes("econnreset"))
37
+ return "CONNECTION_FAILED: Cannot connect to Instagram. The service may be down. Retry in 30s.";
38
+ if (d.includes("fetch failed"))
39
+ return "NETWORK_ERROR: Network request failed. Check your internet connection and retry.";
40
+ return undefined;
41
+ }
42
+
43
+ // Token-invalidity errors can come back as 400 with an OAuthException.
44
+ // Match only explicit token-failure phrasing — NOT the bare word "oauth",
45
+ // because OAuthException is the generic error type for all Graph API failures.
46
+ if (
47
+ d.includes("invalid oauth access token") ||
48
+ d.includes("access token has expired") ||
49
+ d.includes("session has expired") ||
50
+ d.includes("access token is invalid") ||
51
+ d.includes("cannot parse access token") ||
52
+ d.includes("malformed access token")
53
+ ) {
54
+ return "AUTH_FAILED: Access token is invalid or expired. Generate a new long-lived token via the Facebook Developer Console.";
55
+ }
56
+
57
+ switch (statusCode) {
58
+ case 400:
59
+ if (
60
+ (d.includes("invalid") && d.includes("media")) ||
61
+ d.includes("photo or video can be accepted") ||
62
+ d.includes("2207052") ||
63
+ d.includes("2207027")
64
+ )
65
+ 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.";
66
+ if (d.includes("caption") || d.includes("too long"))
67
+ return "CAPTION_TOO_LONG: Caption exceeds 2200 characters. Shorten it and retry.";
68
+ if (d.includes("children") || d.includes("carousel"))
69
+ return "INVALID_CAROUSEL: Carousel requires 2-10 items. Check item count and media URLs.";
70
+ if (d.includes("hashtag"))
71
+ return "INVALID_HASHTAG: Hashtag search is limited to 30 unique hashtags per 7-day rolling window.";
72
+ return "INVALID_REQUEST: Check the error message and fix the input parameters.";
73
+
74
+ case 401:
75
+ return "AUTH_FAILED: Access token is invalid or expired. Generate a new long-lived token via the Facebook Developer Console.";
76
+
77
+ case 403:
78
+ if (d.includes("permission"))
79
+ return "PERMISSION_DENIED: Your token lacks the required permission. Check token permissions in Facebook Developer Console.";
80
+ if (d.includes("not approved") || d.includes("app not"))
81
+ return "APP_NOT_APPROVED: Your Facebook app needs approval for this permission. Submit for review in the App Dashboard.";
82
+ if (d.includes("business"))
83
+ return "BUSINESS_ACCOUNT_REQUIRED: This feature requires an Instagram Business or Creator account linked to a Facebook Page.";
84
+ return "FORBIDDEN: Instagram rejected this action. Check the error message for details.";
85
+
86
+ case 404: {
87
+ const t = toolName.toLowerCase();
88
+ if (t.includes("comment"))
89
+ return "COMMENT_NOT_FOUND: This comment may have been deleted. Skip it and move on.";
90
+ if (t.includes("post") || t.includes("insight"))
91
+ return "MEDIA_NOT_FOUND: This post may have been deleted or the ID is invalid. Skip it.";
92
+ if (t.includes("story"))
93
+ return "STORY_NOT_FOUND: Stories expire after 24 hours. This story is no longer available.";
94
+ return "NOT_FOUND: The requested resource does not exist. It may have been deleted.";
95
+ }
96
+
97
+ case 429:
98
+ return "RATE_LIMITED: Instagram rate limit hit after automatic retries. Wait 60s and retry, or switch to a different task.";
99
+
100
+ case 500:
101
+ case 502:
102
+ case 503:
103
+ return "SERVER_ERROR: Instagram is having issues. Wait 30s and retry once.";
104
+
105
+ default:
106
+ return undefined;
107
+ }
108
+ }
@@ -0,0 +1,75 @@
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
+
11
+ import { describe, it, expect, vi, afterEach } from "vitest";
12
+ import { postFirstComment } from "./index.js";
13
+ import type { InstagramClient } from "./client.js";
14
+
15
+ afterEach(() => {
16
+ vi.useRealTimers();
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ /** Drive the helper to completion, flushing its 3-5s natural-appearance delay. */
21
+ async function runWithTimers<T>(p: Promise<T>): Promise<T> {
22
+ await vi.runAllTimersAsync();
23
+ return p;
24
+ }
25
+
26
+ describe("postFirstComment — partial-success contract", () => {
27
+ it("returns the comment id when the comment posts", async () => {
28
+ vi.useFakeTimers();
29
+ const post = vi.fn().mockResolvedValue({ id: "comment-1" });
30
+ const client = { post } as unknown as InstagramClient;
31
+
32
+ const result = await runWithTimers(
33
+ postFirstComment(client, "media-1", "Great thread →"),
34
+ );
35
+
36
+ expect(result).toEqual({ id: "comment-1" });
37
+ expect(post).toHaveBeenCalledWith("/media-1/comments", {
38
+ message: "Great thread →",
39
+ });
40
+ });
41
+
42
+ it("returns { error } (NOT a clean success) when the comment client throws", async () => {
43
+ vi.useFakeTimers();
44
+ const post = vi.fn().mockRejectedValue(new Error("graph boom"));
45
+ const client = { post } as unknown as InstagramClient;
46
+
47
+ const result = await runWithTimers(
48
+ postFirstComment(client, "media-1", "hi"),
49
+ );
50
+
51
+ // The media is already live; the failure must surface, never be swallowed.
52
+ expect(result.error).toContain("graph boom");
53
+ expect(result.id).toBeUndefined();
54
+ });
55
+
56
+ it("is a no-op (no API call) when firstComment is blank", async () => {
57
+ const post = vi.fn();
58
+ const client = { post } as unknown as InstagramClient;
59
+
60
+ expect(await postFirstComment(client, "media-1", " ")).toEqual({});
61
+ expect(await postFirstComment(client, "media-1", undefined)).toEqual({});
62
+ expect(post).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("caps the comment at Instagram's 2200-char limit", async () => {
66
+ vi.useFakeTimers();
67
+ const post = vi.fn().mockResolvedValue({ id: "c" });
68
+ const client = { post } as unknown as InstagramClient;
69
+
70
+ await runWithTimers(postFirstComment(client, "m", "y".repeat(4000)));
71
+
72
+ const sent = post.mock.calls[0][1] as { message: string };
73
+ expect(sent.message).toHaveLength(2200);
74
+ });
75
+ });