@omnisocials/mcp-server 1.9.0 → 1.11.0
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/build/client.d.ts +12 -0
- package/build/client.js +66 -0
- package/build/index.js +1 -1
- package/build/tools/analytics.js +125 -43
- package/build/tools/posts.js +54 -12
- package/package.json +1 -1
package/build/client.d.ts
CHANGED
|
@@ -9,6 +9,14 @@ export declare function formatDate(iso: string | null): string;
|
|
|
9
9
|
export declare function formatDateTime(iso: string | null): string;
|
|
10
10
|
export declare function truncate(value: unknown, len: number): string;
|
|
11
11
|
export declare function capitalize(str: string): string;
|
|
12
|
+
export declare function sortMetricLabels(labels: string[]): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Flatten a raw per-platform metrics object into ordered [label, value] rows,
|
|
15
|
+
* keeping every numeric counter the platform reported. Strings (note, title,
|
|
16
|
+
* cover_image_url, …) are skipped; `plays` is a legacy always-0 Instagram
|
|
17
|
+
* back-compat key so a zero there is noise, not data.
|
|
18
|
+
*/
|
|
19
|
+
export declare function metricRows(m: Record<string, unknown> | null | undefined): Array<[string, number]>;
|
|
12
20
|
export interface XThreadPartInput {
|
|
13
21
|
/** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
|
|
14
22
|
text: string;
|
|
@@ -238,6 +246,10 @@ export declare class OmniSocialsClient {
|
|
|
238
246
|
platform?: string;
|
|
239
247
|
date?: string;
|
|
240
248
|
}): Promise<ApiResponse<unknown>>;
|
|
249
|
+
getBestTimes(params: {
|
|
250
|
+
platform: string;
|
|
251
|
+
timezone?: string;
|
|
252
|
+
}): Promise<ApiResponse<unknown>>;
|
|
241
253
|
listWebhooks(): Promise<ApiResponse<unknown>>;
|
|
242
254
|
createWebhook(data: {
|
|
243
255
|
url: string;
|
package/build/client.js
CHANGED
|
@@ -89,6 +89,69 @@ export function truncate(value, len) {
|
|
|
89
89
|
export function capitalize(str) {
|
|
90
90
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
91
91
|
}
|
|
92
|
+
// ─── Metric Rendering ───────────────────────────────────────────────────────
|
|
93
|
+
// Canonical render order + labels for per-platform metric objects. Any numeric
|
|
94
|
+
// key the API returns that isn't listed here still renders (prettified from
|
|
95
|
+
// snake_case) so no metric is ever silently dropped — reach/saves/views went
|
|
96
|
+
// missing for months because the old renderers used a fixed column set.
|
|
97
|
+
const METRIC_LABELS = [
|
|
98
|
+
["impressions", "Impressions"],
|
|
99
|
+
["reach", "Reach"],
|
|
100
|
+
["views", "Views"],
|
|
101
|
+
["likes", "Likes"],
|
|
102
|
+
["comments", "Comments"],
|
|
103
|
+
["shares", "Shares"],
|
|
104
|
+
["saves", "Saves"],
|
|
105
|
+
["clicks", "Clicks"],
|
|
106
|
+
["reposts", "Reposts"],
|
|
107
|
+
["quotes", "Quotes"],
|
|
108
|
+
["bookmarks", "Bookmarks"],
|
|
109
|
+
["replies", "Replies"],
|
|
110
|
+
["reactions", "Reactions"],
|
|
111
|
+
["total_reactions", "Total reactions"],
|
|
112
|
+
["favorites", "Favorites"],
|
|
113
|
+
["plays", "Plays"],
|
|
114
|
+
["engagement", "Engagements"],
|
|
115
|
+
];
|
|
116
|
+
// Context keys that ride along in stored metrics — not counters.
|
|
117
|
+
const NON_COUNTER_KEYS = new Set(["create_time"]);
|
|
118
|
+
/** Canonical position of a metric label — for sorting totals rows collected
|
|
119
|
+
* across platforms back into METRIC_LABELS order (unknown labels sink). */
|
|
120
|
+
const LABEL_ORDER = new Map(METRIC_LABELS.map(([, label], i) => [label, i]));
|
|
121
|
+
export function sortMetricLabels(labels) {
|
|
122
|
+
return [...labels].sort((a, b) => (LABEL_ORDER.get(a) ?? 999) - (LABEL_ORDER.get(b) ?? 999));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Flatten a raw per-platform metrics object into ordered [label, value] rows,
|
|
126
|
+
* keeping every numeric counter the platform reported. Strings (note, title,
|
|
127
|
+
* cover_image_url, …) are skipped; `plays` is a legacy always-0 Instagram
|
|
128
|
+
* back-compat key so a zero there is noise, not data.
|
|
129
|
+
*/
|
|
130
|
+
export function metricRows(m) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
for (const [key, label] of METRIC_LABELS) {
|
|
134
|
+
const v = m?.[key];
|
|
135
|
+
if (v === undefined || v === null)
|
|
136
|
+
continue;
|
|
137
|
+
const n = Number(v);
|
|
138
|
+
if (!Number.isFinite(n))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
if (key === "plays" && n === 0)
|
|
142
|
+
continue;
|
|
143
|
+
rows.push([label, n]);
|
|
144
|
+
}
|
|
145
|
+
for (const [key, v] of Object.entries(m || {})) {
|
|
146
|
+
if (seen.has(key) || NON_COUNTER_KEYS.has(key))
|
|
147
|
+
continue;
|
|
148
|
+
const n = Number(v);
|
|
149
|
+
if (!Number.isFinite(n))
|
|
150
|
+
continue;
|
|
151
|
+
rows.push([capitalize(key.replace(/_/g, " ")), n]);
|
|
152
|
+
}
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
92
155
|
export class OmniSocialsClient {
|
|
93
156
|
baseUrl;
|
|
94
157
|
apiKey;
|
|
@@ -224,6 +287,9 @@ export class OmniSocialsClient {
|
|
|
224
287
|
async getAccountAnalytics(params) {
|
|
225
288
|
return this.request("GET", "/analytics/accounts", undefined, params);
|
|
226
289
|
}
|
|
290
|
+
async getBestTimes(params) {
|
|
291
|
+
return this.request("GET", "/analytics/best-times", undefined, params);
|
|
292
|
+
}
|
|
227
293
|
// Webhooks
|
|
228
294
|
async listWebhooks() {
|
|
229
295
|
return this.request("GET", "/webhooks");
|
package/build/index.js
CHANGED
|
@@ -27,7 +27,7 @@ const sessionState = { activeIndex: 0 };
|
|
|
27
27
|
const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
|
|
28
28
|
const server = new McpServer({
|
|
29
29
|
name: "OmniSocials",
|
|
30
|
-
version: "1.
|
|
30
|
+
version: "1.11.0",
|
|
31
31
|
});
|
|
32
32
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
33
33
|
registerPostTools(server, getActiveClient);
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatNumber, capitalize } from "../client.js";
|
|
2
|
+
import { formatNumber, capitalize, metricRows, sortMetricLabels } from "../client.js";
|
|
3
3
|
export function registerAnalyticsTools(server, getClient) {
|
|
4
|
-
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post
|
|
4
|
+
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported — impressions, reach, views, saves, likes, comments, shares, clicks, engagements, and more — per platform.", {
|
|
5
5
|
post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
|
|
6
6
|
}, async ({ post_id }) => {
|
|
7
7
|
const result = await getClient().getPostAnalytics(post_id);
|
|
@@ -26,41 +26,62 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
26
26
|
}],
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// Every numeric metric the platform reported is rendered via metricRows —
|
|
30
|
+
// a fixed column set here is how reach/saves/views silently vanished.
|
|
30
31
|
const perPlatform = [];
|
|
32
|
+
const totalOrder = [];
|
|
33
|
+
const totalsByLabel = new Map();
|
|
34
|
+
let totalEngagements = 0;
|
|
35
|
+
let totalDenom = 0;
|
|
31
36
|
for (const [platform, entry] of platformEntries) {
|
|
32
37
|
const m = entry?.metrics || {};
|
|
33
|
-
const
|
|
34
|
-
? Number(m.reach ?? m.impressions ?? 0)
|
|
35
|
-
: Number(m.views ?? m.impressions ?? 0);
|
|
38
|
+
const rows = metricRows(m);
|
|
36
39
|
const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
37
40
|
const comments = Number(m.comments ?? m.replies ?? 0);
|
|
38
41
|
const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
39
42
|
const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
md
|
|
43
|
+
// Denominator for engagement rate: the broadest exposure count present.
|
|
44
|
+
const denom = Number(m.impressions ?? m.views ?? m.reach ?? 0);
|
|
45
|
+
totalEngagements += engagements;
|
|
46
|
+
totalDenom += denom;
|
|
47
|
+
for (const [label, n] of rows) {
|
|
48
|
+
if (!totalsByLabel.has(label))
|
|
49
|
+
totalOrder.push(label);
|
|
50
|
+
totalsByLabel.set(label, (totalsByLabel.get(label) || 0) + n);
|
|
51
|
+
}
|
|
52
|
+
perPlatform.push({
|
|
53
|
+
platform,
|
|
54
|
+
rows,
|
|
55
|
+
engagements,
|
|
56
|
+
denom,
|
|
57
|
+
note: typeof m.note === "string" ? m.note : undefined,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (!totalsByLabel.has("Engagements"))
|
|
61
|
+
totalOrder.push("Engagements");
|
|
62
|
+
totalsByLabel.set("Engagements", totalEngagements);
|
|
63
|
+
const renderRate = (engagements, denom) => denom > 0 ? `| **Engagement Rate** | ${((engagements / denom) * 100).toFixed(2)}% |\n` : "";
|
|
64
|
+
let md = perPlatform.length === 1
|
|
65
|
+
? `## Post Analytics — ${capitalize(perPlatform[0].platform)}\n`
|
|
66
|
+
: `## Post Analytics\n\n`;
|
|
67
|
+
if (perPlatform.length > 1) {
|
|
68
|
+
md += `| Metric | Value |\n|--------|-------|\n`;
|
|
69
|
+
for (const label of sortMetricLabels(totalOrder)) {
|
|
70
|
+
md += `| **${label}** | ${formatNumber(totalsByLabel.get(label) || 0)} |\n`;
|
|
71
|
+
}
|
|
72
|
+
md += renderRate(totalEngagements, totalDenom);
|
|
73
|
+
md += `\n### Per-Platform Breakdown\n`;
|
|
74
|
+
}
|
|
62
75
|
for (const p of perPlatform) {
|
|
63
|
-
|
|
76
|
+
if (perPlatform.length > 1)
|
|
77
|
+
md += `\n#### ${capitalize(p.platform)}\n`;
|
|
78
|
+
md += `\n| Metric | Value |\n|--------|-------|\n`;
|
|
79
|
+
for (const [label, n] of p.rows) {
|
|
80
|
+
md += `| **${label}** | ${formatNumber(n)} |\n`;
|
|
81
|
+
}
|
|
82
|
+
md += renderRate(p.engagements, p.denom);
|
|
83
|
+
if (p.note)
|
|
84
|
+
md += `\n_${p.note}_\n`;
|
|
64
85
|
}
|
|
65
86
|
return {
|
|
66
87
|
content: [{ type: "text", text: md }],
|
|
@@ -88,17 +109,21 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
88
109
|
const platformEntries = Object.entries(platforms);
|
|
89
110
|
let impressions = 0;
|
|
90
111
|
let engagements = 0;
|
|
91
|
-
|
|
112
|
+
let likes = 0;
|
|
113
|
+
let comments = 0;
|
|
114
|
+
let shares = 0;
|
|
115
|
+
for (const [, p] of platformEntries) {
|
|
92
116
|
const m = p?.metrics || {};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
117
|
+
// Broadest exposure count present, regardless of the platform's name
|
|
118
|
+
// for it (LinkedIn: impressions, TikTok/X/YouTube: views, IG: reach).
|
|
119
|
+
impressions += Number(m.impressions ?? m.views ?? m.reach ?? 0);
|
|
120
|
+
const l = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
121
|
+
const c = Number(m.comments ?? m.replies ?? 0);
|
|
122
|
+
const s = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
123
|
+
likes += l;
|
|
124
|
+
comments += c;
|
|
125
|
+
shares += s;
|
|
126
|
+
engagements += m.engagement != null ? Number(m.engagement) : l + c + s;
|
|
102
127
|
}
|
|
103
128
|
return {
|
|
104
129
|
post_id: entry.post_id,
|
|
@@ -106,16 +131,20 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
106
131
|
platformNames: platformEntries.map(([name]) => capitalize(name)).join(", "),
|
|
107
132
|
impressions,
|
|
108
133
|
engagements,
|
|
134
|
+
likes,
|
|
135
|
+
comments,
|
|
136
|
+
shares,
|
|
109
137
|
};
|
|
110
138
|
});
|
|
111
139
|
const withData = rows.filter((r) => r.platformCount > 0).length;
|
|
112
140
|
let md = `## Posts Analytics (${rows.length} posts, ${withData} with collected stats)\n\n`;
|
|
113
|
-
md += `| Post ID | Platforms | Impressions | Engagements |\n`;
|
|
114
|
-
md +=
|
|
141
|
+
md += `| Post ID | Platforms | Impressions | Engagements | Likes | Comments | Shares |\n`;
|
|
142
|
+
md += `|---------|-----------|-------------|-------------|-------|----------|--------|\n`;
|
|
115
143
|
for (const r of rows) {
|
|
116
|
-
|
|
144
|
+
const c = (n) => (r.platformCount ? formatNumber(n) : "—");
|
|
145
|
+
md += `| ${r.post_id} | ${r.platformNames || "—"} | ${c(r.impressions)} | ${c(r.engagements)} | ${c(r.likes)} | ${c(r.comments)} | ${c(r.shares)} |\n`;
|
|
117
146
|
}
|
|
118
|
-
md += `\nPosts showing "—" have no analytics collected yet. Stats are fetched periodically after publishing; check back in a few hours.\n`;
|
|
147
|
+
md += `\nPosts showing "—" have no analytics collected yet. Stats are fetched periodically after publishing; check back in a few hours. Use get_post_analytics on a single post for the full per-platform metric set (reach, views, saves, clicks, …).\n`;
|
|
119
148
|
return {
|
|
120
149
|
content: [{ type: "text", text: md }],
|
|
121
150
|
};
|
|
@@ -221,4 +250,57 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
221
250
|
content: [{ type: "text", text: md }],
|
|
222
251
|
};
|
|
223
252
|
});
|
|
253
|
+
server.tool("get_best_times", "Get recommended posting times (day + hour) for one platform, computed from the workspace's own posting history: publish time × engagement of every post published there, recency-weighted and outlier-damped, bucketed in the user's timezone. Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, industry-average defaults are returned instead (`basis: defaults`) with how many more posts unlock personalized recommendations — tell the user that so the numbers aren't mistaken for their own audience data. Use this before scheduling when the user asks 'when should I post?' or hasn't specified a time. Requires the analytics:read scope.", {
|
|
254
|
+
platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business"),
|
|
255
|
+
timezone: z.string().optional().describe("IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone."),
|
|
256
|
+
}, async (params) => {
|
|
257
|
+
const result = await getClient().getBestTimes(params);
|
|
258
|
+
if (result.error) {
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const d = result;
|
|
264
|
+
let md = `## Best times to post — ${capitalize(d.platform)} (${d.timezone})\n\n`;
|
|
265
|
+
md += `**Top slots:**\n`;
|
|
266
|
+
(d.recommendations || []).forEach((r, i) => {
|
|
267
|
+
const extras = [];
|
|
268
|
+
if (r.typical_engagement)
|
|
269
|
+
extras.push(`~${formatNumber(r.typical_engagement)} typical engagement`);
|
|
270
|
+
if (r.n)
|
|
271
|
+
extras.push(`${r.n} posts`);
|
|
272
|
+
md += `${i + 1}. **${capitalize(r.day)} ${r.time}** (score ${r.score}${extras.length ? `, ${extras.join(", ")}` : ""})\n`;
|
|
273
|
+
});
|
|
274
|
+
// Per-day compact breakdown: up to 2 best hours per day, in week order.
|
|
275
|
+
const byDay = new Map();
|
|
276
|
+
for (const cell of d.grid || []) {
|
|
277
|
+
if (!byDay.has(cell.day))
|
|
278
|
+
byDay.set(cell.day, []);
|
|
279
|
+
byDay.get(cell.day).push(cell);
|
|
280
|
+
}
|
|
281
|
+
const weekOrder = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
|
282
|
+
const dayRows = weekOrder
|
|
283
|
+
.filter((day) => byDay.has(day))
|
|
284
|
+
.map((day) => {
|
|
285
|
+
const top = byDay
|
|
286
|
+
.get(day)
|
|
287
|
+
.sort((a, b) => b.score - a.score)
|
|
288
|
+
.slice(0, 2)
|
|
289
|
+
.map((c) => `${String(c.hour).padStart(2, "0")}:00 (${c.score})`)
|
|
290
|
+
.join(", ");
|
|
291
|
+
return `| ${capitalize(day)} | ${top} |`;
|
|
292
|
+
});
|
|
293
|
+
if (dayRows.length) {
|
|
294
|
+
md += `\n### Best hours per day (score 0-100)\n\n| Day | Best hours |\n|-----|------------|\n${dayRows.join("\n")}\n`;
|
|
295
|
+
}
|
|
296
|
+
if (d.basis === "defaults") {
|
|
297
|
+
md += `\n_Not enough posting history on ${capitalize(d.platform)} yet (${d.sample_size} analyzed post${d.sample_size === 1 ? "" : "s"}). These are cross-industry averages — publish ${d.posts_needed} more post${d.posts_needed === 1 ? "" : "s"} to unlock recommendations from this audience's own data._\n`;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
md += `\n_Based on ${d.sample_size} ${capitalize(d.platform)} posts from the last ${d.window_days} days (metric: ${d.metric}, times in ${d.timezone})._\n`;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text", text: md }],
|
|
304
|
+
};
|
|
305
|
+
});
|
|
224
306
|
}
|
package/build/tools/posts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatDateTime, truncate, capitalize, formatNumber } from "../client.js";
|
|
2
|
+
import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
|
|
3
3
|
// Reusable per-platform option objects for the "first comment" feature — text
|
|
4
4
|
// auto-posted as a comment on the post right after it publishes (hashtags out
|
|
5
5
|
// of the caption, "link in first comment", etc.). Only platforms with a
|
|
@@ -140,7 +140,7 @@ export function registerPostTools(server, getClient) {
|
|
|
140
140
|
content: [{ type: "text", text: md }],
|
|
141
141
|
};
|
|
142
142
|
});
|
|
143
|
-
server.tool("get_post", "Get details of a specific post by ID. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
|
|
143
|
+
server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs), first comment, Instagram collaborators/user tags/location, dates and live URLs, enough to fully verify a scheduled post without opening the dashboard. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
|
|
144
144
|
id: z.string().describe("The post ID"),
|
|
145
145
|
}, async ({ id }) => {
|
|
146
146
|
const result = await getClient().getPost(id);
|
|
@@ -171,10 +171,45 @@ export function registerPostTools(server, getClient) {
|
|
|
171
171
|
md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
|
|
172
172
|
if (appUrl)
|
|
173
173
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
// Media can be a flat array (same set everywhere) or a per-platform
|
|
175
|
+
// object ({ default: [...], instagram: [...] }). The old `p.media.length`
|
|
176
|
+
// check silently dropped the object shape (`.length` is undefined).
|
|
177
|
+
const mediaGroups = Array.isArray(p.media)
|
|
178
|
+
? (p.media.length ? [["all", p.media]] : [])
|
|
179
|
+
: Object.entries(p.media || {}).filter((e) => Array.isArray(e[1]) && e[1].length > 0);
|
|
180
|
+
const mediaUrlOf = (m) => (typeof m === "string" ? m : m?.url) || null;
|
|
181
|
+
const uniqueMediaUrls = new Set(mediaGroups.flatMap(([, items]) => items.map(mediaUrlOf).filter(Boolean)));
|
|
182
|
+
if (uniqueMediaUrls.size) {
|
|
183
|
+
md += `| **Media** | ${uniqueMediaUrls.size} file(s) |\n`;
|
|
184
|
+
}
|
|
185
|
+
// Instagram extras, echoed top-level by the API to mirror create_post.
|
|
186
|
+
if (p.location_id)
|
|
187
|
+
md += `| **IG location** | \`${p.location_id}\` |\n`;
|
|
188
|
+
if (Array.isArray(p.collaborators) && p.collaborators.length) {
|
|
189
|
+
md += `| **IG collaborators** | ${p.collaborators.map((c) => `@${c}`).join(", ")} |\n`;
|
|
190
|
+
}
|
|
191
|
+
if (Array.isArray(p.user_tags) && p.user_tags.length) {
|
|
192
|
+
md += `| **IG user tags** | ${p.user_tags
|
|
193
|
+
.map((t) => `@${typeof t === "string" ? t : t?.username || "?"}`)
|
|
194
|
+
.join(", ")} |\n`;
|
|
176
195
|
}
|
|
177
196
|
md += `\n### Content\n\n${renderContent(p.content)}`;
|
|
197
|
+
// Media URLs — so a caller can verify exactly what's attached (and in
|
|
198
|
+
// what order) without opening the dashboard.
|
|
199
|
+
if (mediaGroups.length) {
|
|
200
|
+
md += `\n\n### Media\n\n`;
|
|
201
|
+
const flat = mediaGroups.length === 1 && mediaGroups[0][0] === "all";
|
|
202
|
+
for (const [group, items] of mediaGroups) {
|
|
203
|
+
if (!flat) {
|
|
204
|
+
md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
|
|
205
|
+
}
|
|
206
|
+
items.forEach((m, i) => {
|
|
207
|
+
md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}\n`;
|
|
208
|
+
});
|
|
209
|
+
md += `\n`;
|
|
210
|
+
}
|
|
211
|
+
md = md.trimEnd();
|
|
212
|
+
}
|
|
178
213
|
// X thread: when present, the canonical tweet text lives here, not in `content`.
|
|
179
214
|
const threadParts = Array.isArray(p.x?.thread_parts)
|
|
180
215
|
? p.x.thread_parts
|
|
@@ -242,13 +277,15 @@ export function registerPostTools(server, getClient) {
|
|
|
242
277
|
const firstCommentRows = [];
|
|
243
278
|
for (const platform of firstCommentPlatforms) {
|
|
244
279
|
const opts = p[platform];
|
|
245
|
-
|
|
246
|
-
if (typeof text !== "string" || !text.trim())
|
|
280
|
+
if (!opts || typeof opts !== "object")
|
|
247
281
|
continue;
|
|
282
|
+
const text = typeof opts.first_comment === "string" ? opts.first_comment.trim() : "";
|
|
248
283
|
const result = opts.first_comment_result;
|
|
284
|
+
if (!text && !result)
|
|
285
|
+
continue;
|
|
249
286
|
const status = result && typeof result === "object" ? result.status : undefined;
|
|
250
287
|
const statusLabel = status ? ` — _${status}${result?.error ? `: ${result.error}` : ""}_` : "";
|
|
251
|
-
firstCommentRows.push(`- **${capitalize(platform.replace(/_/g, " "))}**${statusLabel}: ${truncate(text, 200)}`);
|
|
288
|
+
firstCommentRows.push(`- **${capitalize(platform.replace(/_/g, " "))}**${statusLabel}: ${text ? truncate(text, 200) : "*(none)*"}`);
|
|
252
289
|
}
|
|
253
290
|
if (firstCommentRows.length) {
|
|
254
291
|
md += `\n\n### First Comments\n\n${firstCommentRows.join("\n")}\n`;
|
|
@@ -775,7 +812,7 @@ Notes:
|
|
|
775
812
|
});
|
|
776
813
|
return { content: [{ type: "text", text: md }] };
|
|
777
814
|
});
|
|
778
|
-
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement`
|
|
815
|
+
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Requires the analytics:read scope.", {
|
|
779
816
|
limit: z
|
|
780
817
|
.string()
|
|
781
818
|
.optional()
|
|
@@ -804,15 +841,20 @@ Notes:
|
|
|
804
841
|
};
|
|
805
842
|
}
|
|
806
843
|
let md = `## Recent platform posts (${posts.length} across ${connected.length} platform${connected.length === 1 ? "" : "s"})\n\n`;
|
|
807
|
-
md += `| # | Platform | Format | Content | Engagement |
|
|
808
|
-
md +=
|
|
844
|
+
md += `| # | Platform | Format | Content | Engagement | Metrics | Date |\n`;
|
|
845
|
+
md += `|---|----------|--------|---------|------------|---------|------|\n`;
|
|
809
846
|
posts.forEach((p, i) => {
|
|
810
847
|
const hasMetrics = p.metrics && Object.keys(p.metrics).length > 0;
|
|
811
848
|
const content = truncate(clean(p.text), 45) || "*(no caption)*";
|
|
812
849
|
const eng = hasMetrics ? formatNumber(p.engagement || 0) : "—";
|
|
813
|
-
|
|
850
|
+
// Every counter the platform reported, compact: "1.4K views · 12 saves".
|
|
851
|
+
// Engagement has its own column, so skip its row here.
|
|
852
|
+
const detail = metricRows(p.metrics)
|
|
853
|
+
.filter(([label]) => label !== "Engagements")
|
|
854
|
+
.map(([label, n]) => `${formatNumber(n)} ${label.toLowerCase()}`)
|
|
855
|
+
.join(" · ");
|
|
814
856
|
const date = p.timestamp ? formatDateTime(p.timestamp) : "—";
|
|
815
|
-
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${
|
|
857
|
+
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
|
|
816
858
|
});
|
|
817
859
|
if (result.note)
|
|
818
860
|
md += `\n${result.note}\n`;
|
package/package.json
CHANGED