@omnisocials/mcp-server 1.35.0 → 1.46.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/metrics.js CHANGED
@@ -57,6 +57,14 @@ export function getEngagement(platform, metrics) {
57
57
  return sum("likes", "comments", "reposts", "quotes", "bookmarks");
58
58
  case "mastodon":
59
59
  return sum("likes", "comments", "reposts", "quotes");
60
+ case "telegram":
61
+ // Reactions are the only post number a bot gets (message_reaction_count),
62
+ // stored as `likes` (total) next to `reactions` (per emoji).
63
+ return sum("likes");
64
+ case "discord":
65
+ // A webhook reads back its own message: reactions only (2026-10-22),
66
+ // stored the Telegram way, `likes` (total) next to `reactions` (per emoji).
67
+ return sum("likes");
60
68
  case "reddit":
61
69
  // likes == upvote score; shares == crossposts.
62
70
  return sum("likes", "comments", "shares");
@@ -129,6 +137,7 @@ export const UNSUPPORTED_POST_METRICS = {
129
137
  reach: "X does not report reach, only impressions.",
130
138
  },
131
139
  facebook: {
140
+ reach: "Facebook stopped reporting post reach in June 2026: Meta retired the metric and its replacement returns no data.",
132
141
  saves: "Facebook does not report saves.",
133
142
  },
134
143
  instagram: {
@@ -176,6 +185,28 @@ export const UNSUPPORTED_POST_METRICS = {
176
185
  video_views: "Mastodon does not report video views.",
177
186
  engagement_rate: "Engagement rate needs impressions, which Mastodon does not report.",
178
187
  },
188
+ telegram: {
189
+ impressions: "Telegram does not give view counts to bots. Open the post in Telegram to see its views.",
190
+ reach: "Telegram does not report reach to bots.",
191
+ comments: "Telegram does not report comment counts to bots.",
192
+ shares: "Telegram does not report forwards to bots.",
193
+ saves: "Telegram does not report saves.",
194
+ clicks: "Telegram does not report clicks.",
195
+ link_clicks: "Telegram does not report clicks.",
196
+ video_views: "Telegram does not give view counts to bots.",
197
+ engagement_rate: "Engagement rate needs views, which Telegram does not give to bots.",
198
+ },
199
+ discord: {
200
+ impressions: "Discord does not report views. Open the message in Discord to see who reacted.",
201
+ reach: "Discord does not report reach.",
202
+ comments: "Discord does not report replies to a webhook message.",
203
+ shares: "Discord does not report shares.",
204
+ saves: "Discord does not report saves.",
205
+ clicks: "Discord does not report clicks.",
206
+ link_clicks: "Discord does not report clicks.",
207
+ video_views: "Discord does not report video views.",
208
+ engagement_rate: "Engagement rate needs views, which Discord does not report.",
209
+ },
179
210
  google_business: {
180
211
  impressions: "Google discontinued per-post metrics for Business Profiles in 2023.",
181
212
  reach: "Google discontinued per-post metrics for Business Profiles in 2023.",
@@ -190,13 +221,19 @@ export const UNSUPPORTED_POST_METRICS = {
190
221
  engagement_rate: "Google discontinued per-post metrics for Business Profiles in 2023.",
191
222
  },
192
223
  };
193
- /** Reason `metricKey` is unavailable on `platform`, or null when it is available. */
194
- export function unsupportedMetricReason(platform, metricKey) {
224
+ /** Metrics unavailable for some post types only: platform -> content_type -> metric key -> reason. Empty since Facebook reach moved to the platform-wide map. */
225
+ export const UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE = {};
226
+ /** Reason `metricKey` is unavailable on `platform` (refined by `contentType`), or null when it is available. */
227
+ export function unsupportedMetricReason(platform, metricKey, contentType) {
228
+ const typed = contentType ? UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE[platform]?.[contentType]?.[metricKey] : undefined;
229
+ if (typed)
230
+ return typed;
195
231
  return UNSUPPORTED_POST_METRICS[platform]?.[metricKey] ?? null;
196
232
  }
197
- /** True when the platform can never report `metricKey`; a finite stored value overrides the map. */
233
+ /** True when the platform can never report `metricKey` for this post; a finite stored value overrides the map. */
198
234
  export function isMetricUnsupported(platform, metricKey, metrics) {
199
- if (!unsupportedMetricReason(platform, metricKey))
235
+ const contentType = typeof metrics?.content_type === "string" ? metrics.content_type : undefined;
236
+ if (!unsupportedMetricReason(platform, metricKey, contentType))
200
237
  return false;
201
238
  if (metrics && finiteOrNull(metrics[metricKey]) !== null)
202
239
  return false;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { fetchImageAsBase64, capitalize } from "../client.js";
3
3
  export function registerAccountTools(server, getClient) {
4
- server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, { title: "List Connected Accounts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
4
+ server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). Telegram accounts are a channel or group the OmniSocials bot posts to; `platform_details` carries `chat_type` (channel, supergroup or group), `subscribers` and `url`. Discord accounts are one Discord channel the workspace posts to through its webhook (posts show the webhook's name and picture); `platform_details` carries `guild_id`, `guild_name` (the server, when connected with Continue with Discord), `channel_id`, `channel_name` (read by the OmniSocials bot or given by the person, else null), `channel_type` (text, voice, announcement, stage, forum or media; a forum or media channel needs `discord.thread_name` on every post), `webhook_name`, `connect_method` (oauth or url) and `url`. LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, { title: "List Connected Accounts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
5
5
  const result = await getClient().listAccounts();
6
6
  if (result.error) {
7
7
  return {
@@ -20,13 +20,21 @@ export function registerAccountTools(server, getClient) {
20
20
  md += `|---|----------|---------|---------------|------------|--------|\n`;
21
21
  for (let i = 0; i < accounts.length; i++) {
22
22
  const a = accounts[i];
23
- const name = a.username ? `@${a.username}` : a.display_name || "—";
23
+ // Discord: the server, then the channel the bot read or the person gave.
24
+ const channel = a.platform === "discord" && a.platform_details?.channel_name ? ` · #${a.platform_details.channel_name}` : "";
25
+ const name = `${a.username ? `@${a.username}` : a.display_name || "—"}${channel}`;
24
26
  const types = (a.content_types || []).join(", ");
25
27
  const platform = capitalize(a.platform);
26
28
  const subType = a.platform_details?.subscription_type;
29
+ const chatType = a.platform === "telegram" ? a.platform_details?.chat_type : null;
30
+ const guildName = a.platform === "discord" ? a.platform_details?.guild_name : null;
27
31
  const sub = subType === "Premium" || subType === "PremiumPlus"
28
32
  ? ` (${subType})`
29
- : "";
33
+ : chatType
34
+ ? ` (${chatType === "channel" ? "channel" : "group"})`
35
+ : guildName && guildName !== a.display_name
36
+ ? ` (${guildName})`
37
+ : "";
30
38
  const statusCell = a.needs_reconnect
31
39
  ? `⚠️ needs_reconnect`
32
40
  : "active";
@@ -93,6 +101,30 @@ export function registerAccountTools(server, getClient) {
93
101
  if (getSubType === "Premium" || getSubType === "PremiumPlus") {
94
102
  md += `| **Subscription** | ${getSubType} |\n`;
95
103
  }
104
+ if (a.platform === "telegram" && a.platform_details) {
105
+ const d = a.platform_details;
106
+ if (d.chat_type)
107
+ md += `| **Chat type** | ${d.chat_type} |\n`;
108
+ if (typeof d.subscribers === "number")
109
+ md += `| **Subscribers** | ${d.subscribers.toLocaleString("en-US")} |\n`;
110
+ if (d.url)
111
+ md += `| **Link** | ${d.url} |\n`;
112
+ }
113
+ if (a.platform === "discord" && a.platform_details) {
114
+ const d = a.platform_details;
115
+ if (d.guild_name)
116
+ md += `| **Server** | ${d.guild_name} |\n`;
117
+ if (d.channel_name)
118
+ md += `| **Channel** | #${d.channel_name}${d.channel_type ? ` (${d.channel_type})` : ""} |\n`;
119
+ if (d.webhook_name)
120
+ md += `| **Posts as** | ${d.webhook_name} (the webhook's name) |\n`;
121
+ if (d.channel_id)
122
+ md += `| **Channel ID** | ${d.channel_id} |\n`;
123
+ if (d.connect_method)
124
+ md += `| **Connected with** | ${d.connect_method === "oauth" ? "Continue with Discord" : "a webhook URL"} |\n`;
125
+ if (d.url)
126
+ md += `| **Link** | ${d.url} |\n`;
127
+ }
96
128
  if (a.boards?.length) {
97
129
  md += `\n### Pinterest Boards\n\n`;
98
130
  md += `| Board Name | Board ID |\n`;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels } from "../client.js";
3
3
  import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
4
4
  export function registerAnalyticsTools(server, getClient) {
5
- server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky and Mastodon; link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
5
+ server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon, Telegram, Discord and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization). Telegram gives bots no view counts: a Telegram post carries likes (all reactions), reactions (per emoji) and, for a poll, the votes. Discord reports the same for a webhook message: likes (all reactions), reactions (per emoji) and the poll votes, no views.", {
6
6
  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."),
7
7
  }, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
@@ -310,7 +310,7 @@ export function registerAnalyticsTools(server, getClient) {
310
310
  };
311
311
  });
312
312
  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, and blended with when the account's followers are online when the platform provides that (Instagram, TikTok Business; `basis: own_data_and_audience`, response carries `audience_online`). Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, the audience-online profile alone is used (`basis: audience`) or industry-average defaults are returned (`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.", {
313
- platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business"),
313
+ platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business, telegram, discord"),
314
314
  timezone: z.string().optional().describe("IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone."),
315
315
  }, { title: "Get Best Times to Post", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
316
316
  const result = await getClient().getBestTimes(params);
@@ -0,0 +1,25 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { OmniSocialsClient } from "../client.js";
3
+ import type { Automation, AutomationInput } from "../types.js";
4
+ export declare const automationChannelLabel: (platform: string | null | undefined) => "Facebook Page" | "Instagram";
5
+ type Fields = {
6
+ platform?: "instagram" | "facebook";
7
+ name?: string;
8
+ keywords?: string[];
9
+ match?: "word" | "contains" | "any";
10
+ dm_text?: string;
11
+ public_replies?: string[];
12
+ triggers?: Array<"comment" | "dm" | "story_reply">;
13
+ scope?: "post" | "next_post" | "any";
14
+ post_id?: string;
15
+ instagram_media_id?: string;
16
+ facebook_post_id?: string;
17
+ max_sends?: number | null;
18
+ stop_at?: string | null;
19
+ };
20
+ export declare function buildAutomationBody(f: Fields, { create }: {
21
+ create: boolean;
22
+ }): AutomationInput;
23
+ export declare function describeAutomation(a: Automation): string;
24
+ export declare function registerAutomationTools(server: McpServer, getClient: () => OmniSocialsClient): void;
25
+ export {};
@@ -0,0 +1,199 @@
1
+ import { z } from "zod";
2
+ import { formatDateTime, truncate } from "../client.js";
3
+ const SCOPE_HINT = "The API key needs the opt-in Automations scopes (OmniSocials Settings > API > create a key with Automations on: automations:read and automations:write).";
4
+ const errorText = (error) => `Error (${error.code}): ${error.message}${error.code === "insufficient_scope" ? `\n\n${SCOPE_HINT}` : ""}`;
5
+ const PLATFORM = z.enum(["instagram", "facebook"]);
6
+ const KINDS = z.enum(["comment", "dm", "story_reply"]);
7
+ // How the channel reads in a table or a sentence.
8
+ export const automationChannelLabel = (platform) => platform === "facebook" ? "Facebook Page" : "Instagram";
9
+ const MATCH = z.enum(["word", "contains", "any"]);
10
+ const SCOPE = z.enum(["post", "next_post", "any"]);
11
+ // The shared fields of create_automation and update_automation, flat so an
12
+ // assistant fills them from one sentence ("DM everyone who comments guide").
13
+ const AUTOMATION_FIELDS = {
14
+ platform: PLATFORM.optional().describe("Where the automation runs: 'instagram' (default) = the connected Instagram account, 'facebook' = the connected Facebook Page (the DM lands in Messenger). instagram_media_id implies 'instagram', facebook_post_id implies 'facebook'. It cannot change after create."),
15
+ name: z.string().max(120).optional().describe("A label for the automations list, e.g. 'Free guide'."),
16
+ keywords: z.array(z.string()).max(20).optional().describe("Keywords that start the automation (up to 20, matched case-insensitively), e.g. [\"guide\", \"link\"]. Required unless match is 'any'."),
17
+ match: MATCH.optional().describe("word (default): the keyword stands on its own ('guide', 'Guide!' match; 'linkedin' does not match 'link'). contains: the keyword appears anywhere. any: every comment or message with text (ask the user before using it)."),
18
+ dm_text: z.string().max(1000).optional().describe("The DM sent to the person (max 1000 characters), once per person. On Facebook it lands in Messenger. Required on create. Confirm the text with the user before creating the automation: it goes out in the account's name."),
19
+ public_replies: z.array(z.string().max(300)).max(10).optional().describe("Optional public replies posted under the comment (one is picked at random per comment), e.g. [\"Sent you a DM!\", \"Check your inbox\"]. Comment trigger only. An empty array turns the public reply off."),
20
+ triggers: z.array(KINDS).min(1).optional().describe("What starts the automation. 'comment' (default) = a comment on an Instagram post or reel, or on a Facebook Page post; 'dm' = a keyword in a DM to the Instagram account, or in a Messenger message to the Facebook Page; 'story_reply' = a keyword in a reply to an Instagram story (Instagram only: Facebook has no story replies). One automation can listen to several."),
21
+ scope: SCOPE.optional().describe("For the comment trigger: 'post' (default) = one post (pass post_id, instagram_media_id or facebook_post_id), 'next_post' = the next post the workspace publishes on this platform, 'any' = every post of the account or Page."),
22
+ post_id: z.string().optional().describe("For scope 'post': the OmniSocials post id (from list_posts) that goes to the automation's platform. A scheduled post starts listening once it is live. One automation per post and platform: a post that goes to Instagram and Facebook can have one of each."),
23
+ instagram_media_id: z.string().optional().describe("For scope 'post' on Instagram: the numeric Instagram media id of a post or reel that was NOT published through OmniSocials. OmniSocials reads it from Instagram to check it belongs to the connected account. Pass only one of post_id, instagram_media_id or facebook_post_id."),
24
+ facebook_post_id: z.string().optional().describe("For scope 'post' on Facebook: the id of a Facebook Page post that was NOT published through OmniSocials ({page-id}_{post-id}, or the post id alone). OmniSocials reads it from Facebook to check it belongs to the connected Page. Implies platform 'facebook'. Pass only one of post_id, instagram_media_id or facebook_post_id."),
25
+ max_sends: z.number().int().positive().nullable().optional().describe("Stop after this many DMs (the automation becomes 'completed'). null = no limit."),
26
+ stop_at: z.string().nullable().optional().describe("Stop at this time (ISO 8601). null = no end."),
27
+ };
28
+ // Turn the flat tool fields into the API body. Only the sections that were
29
+ // given are sent, so update_automation leaves the rest untouched.
30
+ export function buildAutomationBody(f, { create }) {
31
+ const body = {};
32
+ if (f.name !== undefined)
33
+ body.name = f.name;
34
+ if (f.post_id !== undefined)
35
+ body.post_id = f.post_id;
36
+ if (f.platform !== undefined)
37
+ body.platform = f.platform;
38
+ if (f.instagram_media_id !== undefined)
39
+ body.instagram_media_id = f.instagram_media_id;
40
+ if (f.facebook_post_id !== undefined)
41
+ body.facebook_post_id = f.facebook_post_id;
42
+ if (create || f.keywords !== undefined || f.match !== undefined || f.triggers !== undefined || f.scope !== undefined) {
43
+ body.trigger = {};
44
+ if (f.triggers !== undefined)
45
+ body.trigger.kinds = f.triggers;
46
+ if (f.scope !== undefined)
47
+ body.trigger.scope = f.scope;
48
+ if (f.keywords !== undefined)
49
+ body.trigger.keywords = f.keywords;
50
+ if (f.match !== undefined)
51
+ body.trigger.match = f.match;
52
+ }
53
+ if (create || f.dm_text !== undefined || f.public_replies !== undefined) {
54
+ body.actions = {
55
+ dm: { text: f.dm_text ?? "" },
56
+ ...(f.public_replies !== undefined
57
+ ? { public_reply: { enabled: f.public_replies.length > 0, variations: f.public_replies } }
58
+ : {}),
59
+ };
60
+ }
61
+ if (f.max_sends !== undefined || f.stop_at !== undefined) {
62
+ body.limits = {};
63
+ if (f.max_sends !== undefined)
64
+ body.limits.max_sends = f.max_sends;
65
+ if (f.stop_at !== undefined)
66
+ body.limits.stop_at = f.stop_at;
67
+ }
68
+ return body;
69
+ }
70
+ export function describeAutomation(a) {
71
+ const kinds = a.trigger.kinds.join(", ");
72
+ const scope = a.trigger.scope ? ` on ${a.trigger.scope === "post" ? "one post" : a.trigger.scope === "next_post" ? "the next post" : "every post"}` : "";
73
+ const keywords = a.trigger.match === "any" ? "any text" : a.trigger.keywords.map((k) => `"${k}"`).join(", ");
74
+ let md = `## Automation \`${a.id}\`${a.name ? ` "${a.name}"` : ""}\n\n`;
75
+ md += `| Field | Value |\n|-------|-------|\n`;
76
+ md += `| **Platform** | ${automationChannelLabel(a.platform)} |\n`;
77
+ md += `| **Status** | ${a.status} |\n`;
78
+ md += `| **Trigger** | ${kinds}${scope}, keywords ${keywords} (${a.trigger.match}) |\n`;
79
+ if (a.post) {
80
+ const label = a.post.caption ? truncate(a.post.caption, 60) : a.post.permalink || a.post.id;
81
+ md += `| **Post** | ${label}${a.post.permalink ? ` (${a.post.permalink})` : ""}${a.post.live === false ? " (not live yet)" : ""} |\n`;
82
+ }
83
+ else if (a.post_id) {
84
+ md += `| **Post** | OmniSocials post \`${a.post_id}\` |\n`;
85
+ }
86
+ md += `| **DM** | ${truncate(a.actions.dm.text, 120)} |\n`;
87
+ if (a.actions.public_reply.enabled && a.actions.public_reply.variations.length) {
88
+ md += `| **Public reply** | ${a.actions.public_reply.variations.map((v) => `"${truncate(v, 40)}"`).join(", ")} |\n`;
89
+ }
90
+ const limits = [a.limits.max_sends ? `max ${a.limits.max_sends} DMs` : null, a.limits.stop_at ? `until ${formatDateTime(a.limits.stop_at)}` : null].filter(Boolean);
91
+ if (limits.length)
92
+ md += `| **Limits** | ${limits.join(", ")} |\n`;
93
+ md += `| **Sent** | ${a.stats.sent} DMs (${a.stats.matched} matched, ${a.stats.skipped} skipped, ${a.stats.failed} failed) |\n`;
94
+ return md;
95
+ }
96
+ export function registerAutomationTools(server, getClient) {
97
+ server.tool("list_automations", "List the comment-to-DM automations of this workspace, on Instagram and on the Facebook Page. An automation DMs a person once when they comment a keyword on a post (or send it in a DM, or in an Instagram story reply), with an optional public reply under the comment. Use it to answer 'which automations run', before creating one on a post that may have one already, or to find the id for update_automation / get_automation_activity.", {
98
+ status: z.enum(["active", "paused", "completed"]).optional().describe("Only automations with this status."),
99
+ post_id: z.string().optional().describe("Only the automations bound to this OmniSocials post (one per platform at most)."),
100
+ platform: PLATFORM.optional().describe("Only the automations of this platform."),
101
+ }, { title: "List Automations", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
102
+ const result = await getClient().listAutomations(params);
103
+ if (result.error)
104
+ return { content: [{ type: "text", text: errorText(result.error) }] };
105
+ const items = Array.isArray(result.data) ? result.data : [];
106
+ if (!items.length) {
107
+ return { content: [{ type: "text", text: "No comment-to-DM automations yet. Create one with create_automation, or pass comment_dm_automation on create_post." }] };
108
+ }
109
+ let md = `## Automations (${items.length})\n\n`;
110
+ md += `| ID | Platform | Status | Trigger | Keywords | Post | Sent |\n|----|----------|--------|---------|----------|------|------|\n`;
111
+ for (const a of items) {
112
+ const post = a.post ? truncate(a.post.caption || a.post.permalink || a.post.id, 40) : a.post_id ? `post ${a.post_id}` : a.trigger.scope === "next_post" ? "next post" : a.trigger.scope === "any" ? "every post" : "";
113
+ const keywords = a.trigger.match === "any" ? "any text" : a.trigger.keywords.join(", ");
114
+ md += `| \`${a.id}\`${a.name ? ` ${a.name}` : ""} | ${automationChannelLabel(a.platform)} | ${a.status} | ${a.trigger.kinds.join(", ")} | ${keywords} | ${post} | ${a.stats.sent} |\n`;
115
+ }
116
+ md += `\nDetails with get_automation, activity with get_automation_activity, pause or edit with update_automation.`;
117
+ return { content: [{ type: "text", text: md }] };
118
+ });
119
+ server.tool("get_automation", "Get one comment-to-DM automation: its platform (Instagram or the Facebook Page), its trigger, the DM text, the public replies, the limits and the counts.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Get Automation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id }) => {
120
+ const result = await getClient().getAutomation(automation_id);
121
+ if (result.error)
122
+ return { content: [{ type: "text", text: errorText(result.error) }] };
123
+ if (!result.data)
124
+ return { content: [{ type: "text", text: "The API returned no automation." }] };
125
+ return { content: [{ type: "text", text: describeAutomation(result.data) }] };
126
+ });
127
+ server.tool("create_automation", "Create a comment-to-DM automation on the connected Instagram account (default) or on the connected Facebook Page (platform 'facebook'): when somebody comments one of the keywords on the post (or sends it in a DM, or in an Instagram story reply, see triggers), the account DMs them dm_text once, and optionally posts one of public_replies under the comment. On Facebook the DM lands in Messenger. Before creating one, show the user the keyword(s) and the exact DM text and get a yes: the DM goes out in their name. Meta rules, the same on both: one private reply per comment, within 7 days of the comment; a follow-up only after the person answers (24-hour window); OmniSocials caps sends at 180 DMs an hour per account and platform. For scope 'post' pass one of post_id (an OmniSocials post, scheduled or live), instagram_media_id (an Instagram post published outside OmniSocials) or facebook_post_id (a Page post published outside OmniSocials). To attach automations to a NEW post, pass comment_dm_automation on create_post instead.", AUTOMATION_FIELDS, { title: "Create Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
128
+ if (!params.dm_text)
129
+ return { content: [{ type: "text", text: "dm_text is required: the DM the person receives." }] };
130
+ const result = await getClient().createAutomation(buildAutomationBody(params, { create: true }));
131
+ if (result.error)
132
+ return { content: [{ type: "text", text: errorText(result.error) }] };
133
+ if (!result.data)
134
+ return { content: [{ type: "text", text: "Automation created, but the API returned no details. List it with list_automations." }] };
135
+ const a = result.data;
136
+ let md = `Created automation \`${a.id}\` (${a.status}).\n\n${describeAutomation(a)}`;
137
+ if (a.post && a.post.live === false)
138
+ md += `\n_The post is not live on ${a.platform === "facebook" ? "the Facebook Page" : "Instagram"} yet; the automation starts listening at publish._`;
139
+ return { content: [{ type: "text", text: md }] };
140
+ });
141
+ server.tool("update_automation", "Change a comment-to-DM automation: pause or resume it (status), new keywords or match, a new DM text or public replies, new limits, or move it to another post (post_id, instagram_media_id or facebook_post_id). Only the fields you pass change. The platform cannot change: to move from Instagram to the Facebook Page, create a new automation. A 'completed' automation starts again when its limits or trigger change.", {
142
+ automation_id: z.string().describe("The automation id (from list_automations)."),
143
+ status: z.enum(["active", "paused"]).optional().describe("'paused' stops it without deleting it; 'active' resumes it."),
144
+ ...AUTOMATION_FIELDS,
145
+ }, { title: "Update Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ automation_id, status, ...fields }) => {
146
+ const body = buildAutomationBody(fields, { create: false });
147
+ if (status)
148
+ body.status = status;
149
+ if (Object.keys(body).length === 0)
150
+ return { content: [{ type: "text", text: "Nothing to change: pass status, keywords, dm_text, public_replies, limits or a post." }] };
151
+ const result = await getClient().updateAutomation(automation_id, body);
152
+ if (result.error)
153
+ return { content: [{ type: "text", text: errorText(result.error) }] };
154
+ if (!result.data)
155
+ return { content: [{ type: "text", text: "Automation updated, but the API returned no details. Read it with get_automation." }] };
156
+ return { content: [{ type: "text", text: `Updated automation \`${automation_id}\`.\n\n${describeAutomation(result.data)}` }] };
157
+ });
158
+ server.tool("delete_automation", "Delete a comment-to-DM automation (Instagram or Facebook Page) and its activity log. DMs that were already sent stay sent. To stop it for a while, use update_automation with status 'paused' instead. Confirm with the user first.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Delete Automation", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ automation_id }) => {
159
+ const result = await getClient().deleteAutomation(automation_id);
160
+ if (result.error)
161
+ return { content: [{ type: "text", text: errorText(result.error) }] };
162
+ return { content: [{ type: "text", text: `Deleted automation \`${automation_id}\`.` }] };
163
+ });
164
+ server.tool("get_automation_activity", "The activity of a comment-to-DM automation, newest first: one row per comment or message it acted on, with the DM sent, a skip and its reason (already got the DM, limit reached, comment older than 7 days, hourly cap), or a failure with the platform's error. On the Facebook Page the person shows by name (Facebook has no @handle). Use it to answer 'how many people got the DM' or 'why did this person not get it'.", {
165
+ automation_id: z.string().describe("The automation id (from list_automations)."),
166
+ outcome: z.enum(["sent", "skipped", "failed", "pending"]).optional().describe("Only rows with this outcome."),
167
+ limit: z.number().int().min(1).max(100).optional().describe("Rows per page (default 25, max 100)."),
168
+ offset: z.number().int().min(0).optional().describe("Rows to skip (for the next page)."),
169
+ }, { title: "Get Automation Activity", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id, ...params }) => {
170
+ // The automation's platform decides how a person shows: an Instagram
171
+ // @handle, or a Facebook name.
172
+ const [result, automation] = await Promise.all([
173
+ getClient().listAutomationEvents(automation_id, params),
174
+ getClient().getAutomation(automation_id).catch(() => null),
175
+ ]);
176
+ if (result.error)
177
+ return { content: [{ type: "text", text: errorText(result.error) }] };
178
+ const onFacebook = automation?.data?.platform === "facebook";
179
+ const rows = Array.isArray(result.data) ? result.data : [];
180
+ const counts = result.counts || {};
181
+ const countLine = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing yet";
182
+ if (!rows.length) {
183
+ return { content: [{ type: "text", text: `## Activity of automation \`${automation_id}\`\n\nTotals: ${countLine}.\n\nNo rows${params.outcome ? ` with outcome ${params.outcome}` : ""}.` }] };
184
+ }
185
+ let md = `## Activity of automation \`${automation_id}\`\n\nTotals: ${countLine}.\n\n`;
186
+ md += `| When | Who | Did | Outcome | Text |\n|------|-----|-----|---------|------|\n`;
187
+ for (const e of rows) {
188
+ const who = e.person.username ? (onFacebook ? e.person.username : `@${e.person.username}`) : e.person.id;
189
+ const did = e.kind === "comment" ? "commented" : e.kind === "dm" ? (onFacebook ? "sent a Messenger message" : "sent a DM") : "replied to a story";
190
+ const outcome = e.outcome === "skipped" ? `skipped: ${e.reason_label || e.reason || ""}` : e.outcome === "failed" ? `failed: ${truncate(e.reason || "", 60)}` : e.outcome;
191
+ const text = e.trigger_text ? `"${truncate(e.trigger_text, 50)}"` : "";
192
+ md += `| ${formatDateTime(e.sent_at || e.created_at)} | ${who} | ${did} | ${outcome} | ${text} |\n`;
193
+ }
194
+ const p = result.pagination;
195
+ if (p?.has_more)
196
+ md += `\n_${p.total} rows in total; pass offset ${p.offset + p.limit} for the next page._`;
197
+ return { content: [{ type: "text", text: md }] };
198
+ });
199
+ }
@@ -80,11 +80,15 @@ function renderNextUnanswered(result) {
80
80
  const c = item.conversation;
81
81
  const who = c.participant?.name || c.participant?.username || "Unknown";
82
82
  let md = `## Next: ${PLATFORM_EMOJI[c.platform] || ""} ${c.platform} ${c.type} from ${who}\n\n`;
83
- md += `Conversation ID: \`${c.conversation_id}\`\nMessage to answer: \`#${item.message.id}\`\n\n`;
83
+ md += `Conversation ID: \`${c.conversation_id}\`\nMessage to answer: \`#${item.message.id}\` (pass it as message_id when replying)\n\n`;
84
+ if (item.reply_window?.open === false) {
85
+ const app = c.platform === "instagram" ? "Instagram" : "Facebook";
86
+ md += `⚠️ **Meta's 24-hour reply window for this DM closed ${formatDateTime(item.reply_window.closes_at)}.** OmniSocials cannot send a reply here (reply_to_inbox answers 422 outside_messaging_window). Tell the user to answer it from the ${app} app; that reply is mirrored into the inbox and clears this item. Or mark_inbox_read to skip it.\n\n`;
87
+ }
84
88
  md += renderInboxPost(item.message.post || c.post);
85
89
  md += renderInboxMessages(item.messages, item.message.id);
86
90
  md += `_${result.remaining || 0} more waiting._\n\n`;
87
- md += `_Reply: reply_to_inbox with the conversation ID (add include_next: true to get the next one in the same call). Skip for good: mark_inbox_read. Comments: hide_inbox_comment / delete_inbox_comment with the message id._`;
91
+ md += `_Reply: reply_to_inbox with the conversation ID and message_id: "${item.message.id}" (add include_next: true to get the next one in the same call). Skip for good: mark_inbox_read. Comments: hide_inbox_comment / delete_inbox_comment with the message id._`;
88
92
  return md;
89
93
  }
90
94
  export function registerInboxTools(server, getClient) {
@@ -104,7 +108,7 @@ export function registerInboxTools(server, getClient) {
104
108
  unanswered: z
105
109
  .boolean()
106
110
  .optional()
107
- .describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs within the 24-hour reply window), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answers."),
111
+ .describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs past the 24-hour reply window included; they cannot be answered through the API but the customer is still waiting), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answers."),
108
112
  limit: z
109
113
  .number()
110
114
  .optional()
@@ -201,8 +205,12 @@ export function registerInboxTools(server, getClient) {
201
205
  ],
202
206
  };
203
207
  });
204
- server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, YouTube, X, or Threads. You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost); a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set); other platforms are text-only. Set include_next: true to get the next conversation that needs an answer back in the same call (same as get_next_unanswered), so the inbox can be worked through as: get_next_unanswered → draft → reply_to_inbox(include_next) → draft → repeat.", {
208
+ server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, YouTube, X, or Threads. You can only reply to conversations that already exist. For a comment or mention, pass message_id (the #id of the comment being answered): comments on one post share a conversation and without it the reply goes under the newest comment on the post. Meta direct-message replies must be within the platform's 24-hour messaging window: a 422 outside_messaging_window means the window has closed, nothing was sent, and the user must answer that DM from the Instagram or Facebook app (mirrored into the inbox) or skip it with mark_inbox_read; do not retry. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost); a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set); other platforms are text-only. Set include_next: true to get the next conversation that needs an answer back in the same call (same as get_next_unanswered), so the inbox can be worked through as: get_next_unanswered → draft → reply_to_inbox(include_next) → draft → repeat. A 409 duplicate_reply means this exact reply already went out to this conversation in the last 10 minutes (an earlier call that looked slow or failed did succeed) or is being sent right now: nothing was sent again, do NOT retry it — continue with get_next_unanswered. A 422 reply_not_allowed means the platform refuses replies to this item for good (Facebook #1705: the comment sits on a share or boosted copy of the post, or the commenter restricts replies; YouTube: the thread's canReply is false because comments are off on the video or the comment was removed or held for review): the item is dropped from get_next_unanswered automatically, tell the user, and do NOT retry.", {
205
209
  conversation_id: z.string().describe("The conversation ID to reply to"),
210
+ message_id: z
211
+ .string()
212
+ .optional()
213
+ .describe("For comment/mention threads: the inbox id of the comment you are answering (the 'Message to answer' #id from get_next_unanswered, or a #id from get_inbox_conversation). Every comment on a post shares one conversation, so without it the reply is posted under the newest comment on the post, which can be a different person than the one you drafted for. Always pass it for comments. Ignored for DMs."),
206
214
  text: z
207
215
  .string()
208
216
  .optional()
@@ -219,9 +227,10 @@ export function registerInboxTools(server, getClient) {
219
227
  .boolean()
220
228
  .optional()
221
229
  .describe("After sending, also return the next conversation that needs an answer (full thread + post context), or 'nothing waiting'"),
222
- }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ conversation_id, text, attachment_url, attachment_type, include_next }) => {
230
+ }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ conversation_id, message_id, text, attachment_url, attachment_type, include_next }) => {
223
231
  const result = await getClient().replyToInboxConversation(conversation_id, {
224
232
  text: text ?? "",
233
+ ...(message_id ? { message_id } : {}),
225
234
  attachment_url,
226
235
  attachment_type,
227
236
  ...(include_next ? { include_next: true } : {}),
@@ -240,7 +249,7 @@ export function registerInboxTools(server, getClient) {
240
249
  }
241
250
  return { content: [{ type: "text", text: md }] };
242
251
  });
243
- server.tool("get_next_unanswered", "Get the next inbox item that needs an answer, with the whole conversation and the post it is on, in one call. Serves the longest-waiting item first: the customer's latest DM with no reply after it (Instagram/Facebook DMs within the 24-hour reply window only, since Meta refuses later replies), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answered (they are mirrored into the inbox), so a thread a colleague answered on their phone is not served again. Instagram mentions are skipped (no reply path). Only unread items are served by default: to skip one for good, mark_inbox_read it; pass include_read to also see read-but-unanswered items. Looks back 30 days. Loop: get_next_unanswered → draft a reply → reply_to_inbox with include_next: true → repeat until nothing is waiting.", {
252
+ server.tool("get_next_unanswered", "Get the next inbox item that needs an answer, with the whole conversation and the post it is on, in one call. An item needs an answer when it is the customer's latest DM with no reply after it, or a comment/mention nobody replied to and that is not hidden. Order: DMs that can still be answered first (Instagram/Facebook DMs inside Meta's 24-hour window, the one closing soonest first, plus X DMs), then Instagram/Facebook DMs whose window has closed (reply_window.open = false: OmniSocials cannot reply, tell the user to answer from the native app or skip with mark_inbox_read), then comments and mentions, oldest first. Replies typed in the native apps count as answered (they are mirrored into the inbox), so a thread a colleague answered on their phone is not served again. Instagram mentions are skipped (no reply path). Only unread items are served by default: to skip one for good, mark_inbox_read it; pass include_read to also see read-but-unanswered items. Looks back 30 days. Loop: get_next_unanswered → draft a reply → reply_to_inbox with the conversation id, message_id = the served message's #id, and include_next: true → repeat until nothing is waiting.", {
244
253
  platform: z
245
254
  .enum(INBOX_PLATFORMS)
246
255
  .optional()
@@ -252,7 +261,7 @@ export function registerInboxTools(server, getClient) {
252
261
  order: z
253
262
  .enum(["oldest", "newest"])
254
263
  .optional()
255
- .describe("oldest (default): the item that has waited longest first; newest: the most recent first"),
264
+ .describe("oldest (default): the item that has waited longest first; newest: the most recent first. Applies within each group (answerable DMs, then expired Instagram/Facebook DMs, then comments); the groups keep their order"),
256
265
  include_read: z
257
266
  .boolean()
258
267
  .optional()
@@ -307,7 +316,7 @@ export function registerInboxTools(server, getClient) {
307
316
  ],
308
317
  };
309
318
  };
310
- server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
319
+ server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying. A 422 cannot_hide means Facebook does not let the Page hide this particular comment (the commenter blocked the Page, their account is deactivated or restricted, or the comment sits on a shared copy of the post): retrying never changes it — tell the user, and use mark_inbox_read on the conversation if it should stop coming up. A 502 hide_not_applied means Instagram accepted the call but still reports the comment as visible (this happens with comments shown under 'Comments from Facebook' on a reel shared to Facebook; they live on Facebook and Instagram's hide does not reach them): nothing changed in the inbox, tell the user to hide it in the Instagram or Facebook app, do not retry.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
311
320
  // Older name from 1.28.0 (when hiding was Threads-only). Kept so existing
312
321
  // setups keep working; same call, same platforms as hide_inbox_comment.
313
322
  server.tool("hide_inbox_reply", "Older name for hide_inbox_comment, kept so existing setups keep working; prefer hide_inbox_comment. Same behaviour: hide (or unhide with hide: false) a comment someone left on one of the workspace's posts, as the post owner, on Facebook, Instagram, TikTok, YouTube or Threads. Takes the message id (the #id in the thread), not the conversation id. A 403 reconnect_required means the account must be reconnected in the dashboard to grant the comment-moderation permission.", hideCommentSchema, { title: "Hide Inbox Comment (older name)", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);