@hubfluencer/mcp 0.5.0 → 0.6.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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Pure, side-effect-light helpers for the series + content-calendar tools
3
+ * (index.ts).
4
+ *
5
+ * Like core.ts / campaign.ts, this lives outside index.ts on purpose: index.ts
6
+ * registers every tool and connects an stdio transport at module load, so a unit
7
+ * test can't import it. The request-shape builders, response summarizers, the
8
+ * batch/bounds validators, and the calendar's 403-scope message augmentation are
9
+ * extracted here so they are testable against a fake client / plain inputs.
10
+ *
11
+ * GROUND TRUTH (verified against the API controllers + contexts, 2026-07-02):
12
+ * - series_controller.ex, series_episode_controller.ex,
13
+ * scheduled_post_controller.ex + the Series / Posts contexts.
14
+ * - NONE of those three controllers mount `Plugs.Idempotency`, so the series +
15
+ * calendar tools send NO Idempotency-Key (mirroring campaign.ts). A plain
16
+ * transport retry of a create is therefore NOT server-deduplicated; the tool
17
+ * descriptions + `idempotentHint` reflect that honestly. The two exceptions
18
+ * that ARE safe to replay are semantic, not plug-backed: mark-episode-posted
19
+ * and mark-scheduled-posted are CAS transitions whose replay the server
20
+ * defines as a no-op / 422 (see the mark helpers below).
21
+ * - plan_series_episodes meters the FREE daily AiAssistQuota (429
22
+ * `ai_assist_quota_exceeded`, identical shape to the campaign-pack planner)
23
+ * and spends no video credits. materialize_episode creates a 0-credit DRAFT.
24
+ * - The calendar is REMINDER-ONLY by design here: these tools never accept or
25
+ * send a social-account id, so every scheduled_post they create is a manual-
26
+ * posting reminder that the pipeline `WriteScope` floor authorizes with
27
+ * `video:generate`. Attaching a social account (auto-publish) additionally
28
+ * requires `account:admin`, which agent tokens never hold — so a 403
29
+ * `insufficient_scope` here means the TARGETED post already carries an
30
+ * account, or the token predates the reminder-calendar scope split.
31
+ */
32
+
33
+ import { asRecord } from "./core.js";
34
+
35
+ /** The narrow client surface these tools' helpers need — a subset of HubfluencerClient. */
36
+ export interface SeriesCalendarClient {
37
+ get<T = unknown>(
38
+ path: string,
39
+ query?: Record<string, string | number | undefined>,
40
+ ): Promise<T>;
41
+ post<T = unknown>(
42
+ path: string,
43
+ body?: unknown,
44
+ idempotencyKey?: string,
45
+ ): Promise<T>;
46
+ }
47
+
48
+ // ── Series field mapping ──────────────────────────────────────────────────────
49
+
50
+ // The series create/update body accepts exactly these fields
51
+ // (series_controller.ex @series_fields). `campaign_pack_id` is intentionally
52
+ // EXCLUDED — the server sets the series' rolling pack from context on the first
53
+ // materialize, never from user input — so it is not offered here either.
54
+ export const SERIES_FIELDS = [
55
+ "brand_profile_id",
56
+ "product_profile_id",
57
+ "name",
58
+ "template",
59
+ "cadence",
60
+ "tone",
61
+ "default_format",
62
+ "status",
63
+ "metadata",
64
+ ] as const;
65
+
66
+ /** Picks only the accepted series fields from a raw args object, dropping the rest. */
67
+ export function pickSeriesFields(
68
+ args: Record<string, unknown>,
69
+ ): Record<string, unknown> {
70
+ const out: Record<string, unknown> = {};
71
+ for (const k of SERIES_FIELDS) {
72
+ if (args[k] !== undefined) out[k] = args[k];
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // ── Series / episode summarization ────────────────────────────────────────────
78
+
79
+ // A materialized episode deep-links by the format of its draft: editor_ad →
80
+ // /editor/:slug, short → /short/:slug, carousel → /slider/:slug. The serializer
81
+ // already resolves the slug off the (preloaded) campaign_pack_item; surfacing the
82
+ // route saves the agent from re-deriving it and mirrors the pack-item summarizer.
83
+ function episodeDraftRoute(
84
+ format: unknown,
85
+ factorySlug: string | null,
86
+ sliderSlug: string | null,
87
+ ): string | null {
88
+ if (format === "carousel") return sliderSlug ? `/slider/${sliderSlug}` : null;
89
+ if (format === "short") return factorySlug ? `/short/${factorySlug}` : null;
90
+ if (format === "editor_ad")
91
+ return factorySlug ? `/editor/${factorySlug}` : null;
92
+ return null;
93
+ }
94
+
95
+ /** One episode, trimmed to the fields an agent needs to decide the next step. */
96
+ export interface EpisodeSummary {
97
+ id: unknown;
98
+ series_id: unknown;
99
+ position: unknown;
100
+ status: unknown;
101
+ hook: unknown;
102
+ brief: unknown;
103
+ caption: unknown;
104
+ suggested_format: unknown;
105
+ hashtags: unknown;
106
+ posted_at: unknown;
107
+ campaign_pack_item_id: unknown;
108
+ video_factory_slug: string | null;
109
+ slider_slug: string | null;
110
+ draft_slug: string | null;
111
+ draft_route: string | null;
112
+ }
113
+
114
+ /**
115
+ * Summarizes one raw episode (as the SeriesEpisode serializer emits it) into the
116
+ * compact shape the episode tools return. Surfaces the materialized draft slug +
117
+ * its deep-link route (editor_ad/short → the video factory, carousel → the
118
+ * slider) so an agent can see which ideas are still plannable, which are drafted,
119
+ * and where each draft lives.
120
+ */
121
+ export function summarizeEpisode(episode: unknown): EpisodeSummary {
122
+ const e = asRecord(episode);
123
+ const factorySlug =
124
+ typeof e.video_factory_slug === "string" ? e.video_factory_slug : null;
125
+ const sliderSlug = typeof e.slider_slug === "string" ? e.slider_slug : null;
126
+ const draftSlug = factorySlug ?? sliderSlug;
127
+ return {
128
+ id: e.id,
129
+ series_id: e.series_id,
130
+ position: e.position,
131
+ status: e.status,
132
+ hook: e.hook ?? null,
133
+ brief: e.brief ?? null,
134
+ caption: e.caption ?? null,
135
+ suggested_format: e.suggested_format ?? null,
136
+ hashtags: e.hashtags ?? [],
137
+ posted_at: e.posted_at ?? null,
138
+ campaign_pack_item_id: e.campaign_pack_item_id ?? null,
139
+ video_factory_slug: factorySlug,
140
+ slider_slug: sliderSlug,
141
+ draft_slug: draftSlug,
142
+ draft_route: episodeDraftRoute(e.suggested_format, factorySlug, sliderSlug),
143
+ };
144
+ }
145
+
146
+ /** Summarizes a raw series (SeriesController serializer). */
147
+ export function summarizeSeries(series: unknown): Record<string, unknown> {
148
+ const s = asRecord(series);
149
+ return {
150
+ id: s.id,
151
+ name: s.name ?? null,
152
+ template: s.template ?? null,
153
+ cadence: s.cadence ?? null,
154
+ tone: s.tone ?? null,
155
+ default_format: s.default_format ?? null,
156
+ status: s.status ?? null,
157
+ brand_profile_id: s.brand_profile_id ?? null,
158
+ product_profile_id: s.product_profile_id ?? null,
159
+ campaign_pack_id: s.campaign_pack_id ?? null,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Summarizes the read-only series dashboard (SeriesEpisodeController.dashboard):
165
+ * the series plus its episodes grouped by lane (upcoming = ideas, drafted,
166
+ * published) and the per-item performance notes, each episode compacted through
167
+ * summarizeEpisode so the drafted/published lanes carry deep-link routes.
168
+ */
169
+ export function summarizeDashboard(
170
+ dashboard: unknown,
171
+ ): Record<string, unknown> {
172
+ const d = asRecord(dashboard);
173
+ const lane = (v: unknown): EpisodeSummary[] =>
174
+ Array.isArray(v) ? v.map(summarizeEpisode) : [];
175
+ return {
176
+ series: summarizeSeries(d.series),
177
+ upcoming: lane(d.upcoming),
178
+ drafted: lane(d.drafted),
179
+ published: lane(d.published),
180
+ performance_notes: Array.isArray(d.performance_notes)
181
+ ? d.performance_notes
182
+ : [],
183
+ };
184
+ }
185
+
186
+ // ── Scheduled-post (calendar) summarization + bounds ──────────────────────────
187
+
188
+ // The scheduled-post statuses the calendar list can filter on
189
+ // (ScheduledPost.statuses/0). Kept in sync with the controller's ?status= enum.
190
+ export const SCHEDULED_POST_STATUSES = [
191
+ "scheduled",
192
+ "reminded",
193
+ "posted",
194
+ "publishing",
195
+ "published",
196
+ "failed",
197
+ "canceled",
198
+ ] as const;
199
+
200
+ // The platforms a reminder can target (ScheduledPost @platforms). "other" is the
201
+ // pure-reminder platform (no account may ever attach); these tools never attach
202
+ // an account anyway, so all three are reminder-only from the agent's side.
203
+ export const SCHEDULED_POST_PLATFORMS = [
204
+ "tiktok",
205
+ "instagram",
206
+ "other",
207
+ ] as const;
208
+
209
+ // Server bounds mirrored so the tool fails fast with a precise message instead of
210
+ // round-tripping to a 422 (ScheduledPost @max_hashtags / @max_hashtag_length +
211
+ // the caption validate_length). @max_pending_scheduled_posts is surfaced in the
212
+ // description, not enforced client-side (the count is server state).
213
+ export const MAX_CAPTION_LENGTH = 5000;
214
+ export const MAX_HASHTAGS = 30;
215
+ export const MAX_HASHTAG_LENGTH = 100;
216
+ export const MAX_PENDING_SCHEDULED_POSTS = 500;
217
+
218
+ /**
219
+ * Validates the schedule_post caption + hashtags against the ScheduledPost
220
+ * changeset bounds (caption ≤5000, ≤30 hashtags, each ≤100 chars). Returns an
221
+ * error string naming the exact bound, or null when valid. Kept in code (not
222
+ * Zod) so the flat tool schema stays TS2589-safe and the message is actionable.
223
+ */
224
+ export function validateScheduledPostCopy(args: {
225
+ caption?: string;
226
+ hashtags?: string[];
227
+ }): string | null {
228
+ if (
229
+ typeof args.caption === "string" &&
230
+ args.caption.length > MAX_CAPTION_LENGTH
231
+ ) {
232
+ return `caption must be at most ${MAX_CAPTION_LENGTH} characters.`;
233
+ }
234
+ if (args.hashtags !== undefined) {
235
+ if (args.hashtags.length > MAX_HASHTAGS) {
236
+ return `Provide at most ${MAX_HASHTAGS} hashtags (got ${args.hashtags.length}).`;
237
+ }
238
+ const tooLong = args.hashtags.find((h) => h.length > MAX_HASHTAG_LENGTH);
239
+ if (tooLong !== undefined) {
240
+ return `each hashtag must be at most ${MAX_HASHTAG_LENGTH} characters.`;
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Builds the schedule_post create body from the tool args. REMINDER-ONLY: this
248
+ * NEVER threads a social-account id, so the resulting post is always a manual-
249
+ * posting reminder the WriteScope floor authorizes with video:generate. Drops
250
+ * undefined so the server sees a clean shape; blank strings are omitted.
251
+ */
252
+ export function buildScheduledPostBody(args: {
253
+ video_result_id: number;
254
+ platform: string;
255
+ scheduled_at: string;
256
+ caption?: string;
257
+ hashtags?: string[];
258
+ timezone?: string;
259
+ campaign_pack_item_id?: number;
260
+ }): Record<string, unknown> {
261
+ const body: Record<string, unknown> = {
262
+ video_result_id: args.video_result_id,
263
+ platform: args.platform,
264
+ scheduled_at: args.scheduled_at,
265
+ };
266
+ const str = (v: string | undefined) =>
267
+ typeof v === "string" && v.trim() !== "" ? v : undefined;
268
+ const caption = str(args.caption);
269
+ if (caption !== undefined) body.caption = caption;
270
+ if (args.hashtags !== undefined) body.hashtags = args.hashtags;
271
+ const timezone = str(args.timezone);
272
+ if (timezone !== undefined) body.timezone = timezone;
273
+ if (args.campaign_pack_item_id !== undefined)
274
+ body.campaign_pack_item_id = args.campaign_pack_item_id;
275
+ return body;
276
+ }
277
+
278
+ /** One scheduled post, trimmed to what an agent managing a calendar needs. */
279
+ export interface ScheduledPostSummary {
280
+ id: unknown;
281
+ platform: unknown;
282
+ status: unknown;
283
+ video_result_id: unknown;
284
+ video_url: unknown;
285
+ video_factory_kind: unknown;
286
+ campaign_pack_item_id: unknown;
287
+ caption: unknown;
288
+ hashtags: unknown;
289
+ scheduled_at: unknown;
290
+ timezone: unknown;
291
+ posted_at: unknown;
292
+ published_at: unknown;
293
+ error_message: unknown;
294
+ }
295
+
296
+ /**
297
+ * Summarizes one raw scheduled post (ScheduledPostController serializer) into the
298
+ * compact calendar shape. Deliberately DROPS `tiktok_account_id` /
299
+ * `instagram_account_id` / `video_post_id` / `platform_params`: these tools are
300
+ * reminder-only and must not surface (or invite acting on) the auto-publish
301
+ * account binding. `video_url` is the presigned export the user posts manually.
302
+ */
303
+ export function summarizeScheduledPost(post: unknown): ScheduledPostSummary {
304
+ const p = asRecord(post);
305
+ return {
306
+ id: p.id,
307
+ platform: p.platform,
308
+ status: p.status,
309
+ video_result_id: p.video_result_id ?? null,
310
+ video_url: p.video_url ?? null,
311
+ video_factory_kind: p.video_factory_kind ?? null,
312
+ campaign_pack_item_id: p.campaign_pack_item_id ?? null,
313
+ caption: p.caption ?? null,
314
+ hashtags: p.hashtags ?? [],
315
+ scheduled_at: p.scheduled_at ?? null,
316
+ timezone: p.timezone ?? null,
317
+ posted_at: p.posted_at ?? null,
318
+ published_at: p.published_at ?? null,
319
+ error_message: p.error_message ?? null,
320
+ };
321
+ }
322
+
323
+ // ── Calendar 403 (scope) message augmentation ─────────────────────────────────
324
+
325
+ /**
326
+ * Augments a generic `insufficient_scope` (403) message for the reminder-only
327
+ * calendar writes. These tools never send a social-account id, so a scope
328
+ * refusal can only mean one of two things — spell BOTH out so the agent knows it
329
+ * isn't a tool bug and can't fix it by retrying:
330
+ *
331
+ * 1. the TARGETED post already carries a social account (an auto-publish set up
332
+ * in the app), so touching it needs `account:admin`; or
333
+ * 2. the token predates the reminder-calendar scope split (it lacks the write
334
+ * scope the pipeline floor now accepts for reminder-only calendar writes).
335
+ *
336
+ * Publishing stays human/app-only either way. Returns the original message
337
+ * unchanged for any non-403 / non-scope error so other failures read normally.
338
+ */
339
+ export function calendarScopeErrorText(
340
+ status: number | undefined,
341
+ code: string | undefined,
342
+ message: string,
343
+ ): string {
344
+ if (status !== 403 || code !== "insufficient_scope") return message;
345
+ return (
346
+ `${message} This calendar tool only creates manual-posting REMINDERS and never attaches a social ` +
347
+ "account, so this scope refusal means either the targeted post already references a social account " +
348
+ "(an app-only auto-publish that needs account:admin) or the token predates the reminder-calendar " +
349
+ "permission. Publishing stays in the app; reschedule/cancel/mark a reminder-only post instead."
350
+ );
351
+ }