@omnisocials/mcp-server 1.39.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/README.md +55 -2
- package/build/client.d.ts +136 -1
- package/build/client.js +27 -0
- package/build/index.js +3 -1
- package/build/metrics.js +30 -0
- package/build/tools/accounts.js +35 -3
- package/build/tools/analytics.js +2 -2
- package/build/tools/automations.d.ts +25 -0
- package/build/tools/automations.js +199 -0
- package/build/tools/media.js +14 -1
- package/build/tools/posts.js +298 -29
- package/build/types.d.ts +224 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/build/tools/media.js
CHANGED
|
@@ -188,7 +188,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
188
188
|
});
|
|
189
189
|
server.tool("check_media_compatibility", `Check whether a video/image will be accepted by the platforms CONNECTED to this workspace, BEFORE uploading or posting. Use this when the user gives you a file (URL) so you can warn them upfront — e.g. "this 995 MB video won't post to Instagram (max 300 MB), continue?" — and confirm before uploading.
|
|
190
190
|
|
|
191
|
-
Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an existing 'media_id', or explicit 'size_bytes' + 'mime'. Returns the connected platforms and any that would reject the file by size or format.`, {
|
|
191
|
+
Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an existing 'media_id', or explicit 'size_bytes' + 'mime'. Returns the connected platforms and any that would reject the file by size or format, plus IMAGE FIT rows for an image whose size is known (a library file): per connected channel, whether its feed shows the image uncropped and what a post does by default (Instagram crops a 9:16 / wide image to 4:5 / 1.91:1 around the subject; Pinterest keeps a tall Pin and reports it). When a fit row says fits: false, ask the user "crop to <target> (recommended for Instagram: Meta can boost it) or keep the original (the channel cuts it itself)?" and pass the answer as media_fit on create_post.`, {
|
|
192
192
|
url: z.string().optional().describe("Public URL of the file to check. Use this OR media_id OR size_bytes+mime."),
|
|
193
193
|
media_id: z.string().optional().describe("Id of an already-uploaded library item to check."),
|
|
194
194
|
size_bytes: z.number().optional().describe("File size in bytes (use with mime when you already know them)."),
|
|
@@ -225,6 +225,19 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
225
225
|
}
|
|
226
226
|
md += `\nAsk the user whether to continue. It will still post to the platforms not listed above.`;
|
|
227
227
|
}
|
|
228
|
+
const outside = (r.fit || []).filter((f) => !f.fits);
|
|
229
|
+
if (outside.length) {
|
|
230
|
+
const dims = r.file?.width && r.file?.height ? ` (${r.file.width}x${r.file.height})` : "";
|
|
231
|
+
md += `\n\n**Image fit**${dims}: how these channels show it in the feed\n\n| Platform | Ratio | In the feed | A post does |\n|----------|-------|-------------|-------------|\n`;
|
|
232
|
+
for (const f of outside) {
|
|
233
|
+
const does = f.default_action === "crop" ? `crops it to ${f.target} (media_fit "keep" sends the original)` : `keeps it and reports it (media_fit "crop" crops to ${f.target})`;
|
|
234
|
+
md += `| ${f.display_name} | ${f.ratio} | cut to ${f.target} | ${does} |\n`;
|
|
235
|
+
}
|
|
236
|
+
md += `\nAsk the user per channel above: crop to the target (Instagram: Meta can then boost the post) or keep the original (the channel cuts it itself)? Pass the answer as \`media_fit\` on create_post. Stories, reels and the other channels are not affected.`;
|
|
237
|
+
}
|
|
238
|
+
else if ((r.fit || []).length) {
|
|
239
|
+
md += `\n\nImage fit: this image shows uncropped in every connected feed with a rule (${(r.fit || []).map((f) => f.display_name).join(", ")}).`;
|
|
240
|
+
}
|
|
228
241
|
return { content: [{ type: "text", text: md }] };
|
|
229
242
|
});
|
|
230
243
|
server.tool("update_media", "Rename an existing media file or move it into a folder (so it's findable later instead of re-uploading it). Only the fields you pass are changed.", {
|