@jiggai/kitchen-plugin-marketing 0.2.5 → 0.2.7

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,510 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc2) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // src/api/handler.ts
30
+ var handler_exports = {};
31
+ __export(handler_exports, {
32
+ handleRequest: () => handleRequest
33
+ });
34
+ module.exports = __toCommonJS(handler_exports);
35
+ var import_drizzle_orm2 = require("drizzle-orm");
36
+ var import_crypto2 = require("crypto");
37
+
38
+ // src/db/index.ts
39
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"));
40
+ var import_better_sqlite32 = require("drizzle-orm/better-sqlite3");
41
+
42
+ // src/db/schema.ts
43
+ var schema_exports = {};
44
+ __export(schema_exports, {
45
+ accountMetrics: () => accountMetrics,
46
+ accountMetricsRelations: () => accountMetricsRelations,
47
+ media: () => media,
48
+ postMetrics: () => postMetrics,
49
+ postMetricsRelations: () => postMetricsRelations,
50
+ posts: () => posts,
51
+ postsRelations: () => postsRelations,
52
+ socialAccounts: () => socialAccounts,
53
+ socialAccountsRelations: () => socialAccountsRelations,
54
+ templates: () => templates,
55
+ webhooks: () => webhooks
56
+ });
57
+ var import_sqlite_core = require("drizzle-orm/sqlite-core");
58
+ var import_drizzle_orm = require("drizzle-orm");
59
+ var posts = (0, import_sqlite_core.sqliteTable)("posts", {
60
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
61
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
62
+ content: (0, import_sqlite_core.text)("content").notNull(),
63
+ platforms: (0, import_sqlite_core.text)("platforms").notNull(),
64
+ // JSON array
65
+ status: (0, import_sqlite_core.text)("status").notNull(),
66
+ // draft, scheduled, published, failed
67
+ scheduledAt: (0, import_sqlite_core.text)("scheduled_at"),
68
+ // ISO string
69
+ publishedAt: (0, import_sqlite_core.text)("published_at"),
70
+ // ISO string
71
+ tags: (0, import_sqlite_core.text)("tags"),
72
+ // JSON array
73
+ mediaIds: (0, import_sqlite_core.text)("media_ids"),
74
+ // JSON array
75
+ templateId: (0, import_sqlite_core.text)("template_id"),
76
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
77
+ updatedAt: (0, import_sqlite_core.text)("updated_at").notNull(),
78
+ createdBy: (0, import_sqlite_core.text)("created_by").notNull()
79
+ });
80
+ var media = (0, import_sqlite_core.sqliteTable)("media", {
81
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
82
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
83
+ filename: (0, import_sqlite_core.text)("filename").notNull(),
84
+ originalName: (0, import_sqlite_core.text)("original_name").notNull(),
85
+ mimeType: (0, import_sqlite_core.text)("mime_type").notNull(),
86
+ size: (0, import_sqlite_core.integer)("size").notNull(),
87
+ width: (0, import_sqlite_core.integer)("width"),
88
+ height: (0, import_sqlite_core.integer)("height"),
89
+ alt: (0, import_sqlite_core.text)("alt"),
90
+ tags: (0, import_sqlite_core.text)("tags"),
91
+ // JSON array
92
+ url: (0, import_sqlite_core.text)("url").notNull(),
93
+ thumbnailUrl: (0, import_sqlite_core.text)("thumbnail_url"),
94
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
95
+ createdBy: (0, import_sqlite_core.text)("created_by").notNull()
96
+ });
97
+ var templates = (0, import_sqlite_core.sqliteTable)("templates", {
98
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
99
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
100
+ name: (0, import_sqlite_core.text)("name").notNull(),
101
+ content: (0, import_sqlite_core.text)("content").notNull(),
102
+ variables: (0, import_sqlite_core.text)("variables"),
103
+ // JSON array of variable definitions
104
+ tags: (0, import_sqlite_core.text)("tags"),
105
+ // JSON array
106
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
107
+ updatedAt: (0, import_sqlite_core.text)("updated_at").notNull(),
108
+ createdBy: (0, import_sqlite_core.text)("created_by").notNull()
109
+ });
110
+ var socialAccounts = (0, import_sqlite_core.sqliteTable)("social_accounts", {
111
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
112
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
113
+ platform: (0, import_sqlite_core.text)("platform").notNull(),
114
+ // twitter, linkedin, instagram, etc.
115
+ displayName: (0, import_sqlite_core.text)("display_name").notNull(),
116
+ username: (0, import_sqlite_core.text)("username"),
117
+ avatar: (0, import_sqlite_core.text)("avatar"),
118
+ isActive: (0, import_sqlite_core.integer)("is_active", { mode: "boolean" }).notNull().default(true),
119
+ credentials: (0, import_sqlite_core.blob)("credentials").notNull(),
120
+ // Encrypted JSON
121
+ settings: (0, import_sqlite_core.text)("settings"),
122
+ // JSON object
123
+ lastSync: (0, import_sqlite_core.text)("last_sync"),
124
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
125
+ updatedAt: (0, import_sqlite_core.text)("updated_at").notNull()
126
+ });
127
+ var postMetrics = (0, import_sqlite_core.sqliteTable)("post_metrics", {
128
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
129
+ postId: (0, import_sqlite_core.text)("post_id").notNull(),
130
+ platform: (0, import_sqlite_core.text)("platform").notNull(),
131
+ impressions: (0, import_sqlite_core.integer)("impressions").default(0),
132
+ likes: (0, import_sqlite_core.integer)("likes").default(0),
133
+ shares: (0, import_sqlite_core.integer)("shares").default(0),
134
+ comments: (0, import_sqlite_core.integer)("comments").default(0),
135
+ clicks: (0, import_sqlite_core.integer)("clicks").default(0),
136
+ engagementRate: (0, import_sqlite_core.text)("engagement_rate"),
137
+ // Stored as string to avoid float precision issues
138
+ syncedAt: (0, import_sqlite_core.text)("synced_at").notNull()
139
+ });
140
+ var accountMetrics = (0, import_sqlite_core.sqliteTable)("account_metrics", {
141
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
142
+ accountId: (0, import_sqlite_core.text)("account_id").notNull(),
143
+ date: (0, import_sqlite_core.text)("date").notNull(),
144
+ // YYYY-MM-DD format
145
+ followers: (0, import_sqlite_core.integer)("followers").default(0),
146
+ following: (0, import_sqlite_core.integer)("following").default(0),
147
+ posts: (0, import_sqlite_core.integer)("posts").default(0),
148
+ engagement: (0, import_sqlite_core.integer)("engagement").default(0),
149
+ reach: (0, import_sqlite_core.integer)("reach").default(0),
150
+ syncedAt: (0, import_sqlite_core.text)("synced_at").notNull()
151
+ });
152
+ var webhooks = (0, import_sqlite_core.sqliteTable)("webhooks", {
153
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
154
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
155
+ url: (0, import_sqlite_core.text)("url").notNull(),
156
+ events: (0, import_sqlite_core.text)("events").notNull(),
157
+ // JSON array
158
+ secret: (0, import_sqlite_core.text)("secret"),
159
+ isActive: (0, import_sqlite_core.integer)("is_active", { mode: "boolean" }).notNull().default(true),
160
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
161
+ lastTriggered: (0, import_sqlite_core.text)("last_triggered")
162
+ });
163
+ var postsRelations = (0, import_drizzle_orm.relations)(posts, ({ many }) => ({
164
+ metrics: many(postMetrics)
165
+ }));
166
+ var postMetricsRelations = (0, import_drizzle_orm.relations)(postMetrics, ({ one }) => ({
167
+ post: one(posts, {
168
+ fields: [postMetrics.postId],
169
+ references: [posts.id]
170
+ })
171
+ }));
172
+ var socialAccountsRelations = (0, import_drizzle_orm.relations)(socialAccounts, ({ many }) => ({
173
+ metrics: many(accountMetrics)
174
+ }));
175
+ var accountMetricsRelations = (0, import_drizzle_orm.relations)(accountMetrics, ({ one }) => ({
176
+ account: one(socialAccounts, {
177
+ fields: [accountMetrics.accountId],
178
+ references: [socialAccounts.id]
179
+ })
180
+ }));
181
+
182
+ // src/db/index.ts
183
+ var import_migrator = require("drizzle-orm/better-sqlite3/migrator");
184
+ var import_crypto = require("crypto");
185
+ function createDatabase(teamId) {
186
+ const dbPath = process.env.KITCHEN_PLUGIN_DB_PATH || "./data";
187
+ const teamDbFile = `${dbPath}/marketing-${teamId}.db`;
188
+ const sqlite = new import_better_sqlite3.default(teamDbFile);
189
+ const db = (0, import_better_sqlite32.drizzle)(sqlite, { schema: schema_exports });
190
+ return { db, sqlite };
191
+ }
192
+ var ENCRYPTION_KEY = process.env.KITCHEN_ENCRYPTION_KEY || "fallback-key-change-in-production";
193
+ function encryptCredentials(credentials) {
194
+ const plaintext = JSON.stringify(credentials);
195
+ const hash = (0, import_crypto.createHash)("sha256").update(ENCRYPTION_KEY).digest();
196
+ const cipher = (0, import_crypto.createCipher)("aes-256-cbc", hash);
197
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
198
+ encrypted += cipher.final("hex");
199
+ return Buffer.from(encrypted, "hex");
200
+ }
201
+ function initializeDatabase(teamId) {
202
+ const { db, sqlite } = createDatabase(teamId);
203
+ try {
204
+ (0, import_migrator.migrate)(db, { migrationsFolder: "./db/migrations" });
205
+ } catch (error) {
206
+ console.warn("Migration warning:", error.message);
207
+ }
208
+ return { db, sqlite };
209
+ }
210
+
211
+ // src/api/handler.ts
212
+ function apiError(status, error, message, details) {
213
+ const payload = { error, message, details };
214
+ return { status, data: payload };
215
+ }
216
+ function parsePagination(query) {
217
+ const limit = Math.min(parseInt(query.limit || "20", 10) || 20, 100);
218
+ const offset = parseInt(query.offset || "0", 10) || 0;
219
+ return { limit, offset };
220
+ }
221
+ function getTeamId(req) {
222
+ return req.query.team || req.query.teamId || req.headers["x-team-id"] || "default";
223
+ }
224
+ function getUserId(req) {
225
+ return req.headers["x-user-id"] || "system";
226
+ }
227
+ function getPostizConfig(req) {
228
+ const apiKey = req.query.postizApiKey || req.headers["x-postiz-api-key"];
229
+ const baseUrl = req.query.postizBaseUrl || req.headers["x-postiz-base-url"] || "https://api.postiz.com/public/v1";
230
+ if (!apiKey) return null;
231
+ return { apiKey, baseUrl: baseUrl.replace(/\/+$/, "") };
232
+ }
233
+ async function postizFetch(config, path, options) {
234
+ return fetch(`${config.baseUrl}${path}`, {
235
+ ...options,
236
+ headers: {
237
+ "Authorization": config.apiKey,
238
+ "Content-Type": "application/json",
239
+ ...options?.headers || {}
240
+ }
241
+ });
242
+ }
243
+ async function detectProviders(req, teamId) {
244
+ const providers = [];
245
+ const postizCfg = getPostizConfig(req);
246
+ if (postizCfg) {
247
+ try {
248
+ const res = await postizFetch(postizCfg, "/integrations");
249
+ if (res.ok) {
250
+ const data = await res.json();
251
+ const integrations = Array.isArray(data) ? data : data.integrations || [];
252
+ for (const integ of integrations) {
253
+ providers.push({
254
+ id: `postiz:${integ.id}`,
255
+ type: "postiz",
256
+ platform: integ.providerIdentifier || integ.provider || "unknown",
257
+ displayName: integ.name || integ.providerIdentifier || "Postiz account",
258
+ username: integ.username || void 0,
259
+ avatar: integ.picture || integ.avatar || void 0,
260
+ isActive: !integ.disabled,
261
+ capabilities: ["post", "schedule"],
262
+ meta: { postizId: integ.id, provider: integ.providerIdentifier }
263
+ });
264
+ }
265
+ }
266
+ } catch {
267
+ }
268
+ }
269
+ try {
270
+ const fs = await import("fs");
271
+ const path = await import("path");
272
+ const os = await import("os");
273
+ const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
274
+ if (fs.existsSync(configPath)) {
275
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
276
+ const plugins = cfg?.plugins?.entries || {};
277
+ if (plugins.discord?.enabled) {
278
+ providers.push({
279
+ id: "gateway:discord",
280
+ type: "gateway",
281
+ platform: "discord",
282
+ displayName: "Discord (via OpenClaw)",
283
+ isActive: true,
284
+ capabilities: ["post"],
285
+ meta: { channel: "discord" }
286
+ });
287
+ }
288
+ if (plugins.telegram?.enabled) {
289
+ providers.push({
290
+ id: "gateway:telegram",
291
+ type: "gateway",
292
+ platform: "telegram",
293
+ displayName: "Telegram (via OpenClaw)",
294
+ isActive: true,
295
+ capabilities: ["post"],
296
+ meta: { channel: "telegram" }
297
+ });
298
+ }
299
+ }
300
+ } catch {
301
+ }
302
+ return providers;
303
+ }
304
+ async function postizPublish(config, body) {
305
+ const payload = {
306
+ content: body.content,
307
+ integrationIds: body.integrationIds
308
+ };
309
+ if (body.scheduledAt) {
310
+ payload.date = body.scheduledAt;
311
+ }
312
+ if (body.settings) {
313
+ payload.settings = body.settings;
314
+ }
315
+ if (body.mediaUrls && body.mediaUrls.length > 0) {
316
+ payload.media = body.mediaUrls.map((url) => ({ url }));
317
+ }
318
+ const res = await postizFetch(config, "/posts", {
319
+ method: "POST",
320
+ body: JSON.stringify(payload)
321
+ });
322
+ const data = await res.json().catch(() => null);
323
+ if (!res.ok) {
324
+ return apiError(res.status, "POSTIZ_ERROR", data?.message || `Postiz returned ${res.status}`, data);
325
+ }
326
+ return { status: 201, data };
327
+ }
328
+ async function handleRequest(req, ctx) {
329
+ const teamId = getTeamId(req);
330
+ if (req.path === "/providers" && req.method === "GET") {
331
+ try {
332
+ const providers = await detectProviders(req, teamId);
333
+ return { status: 200, data: { providers } };
334
+ } catch (error) {
335
+ return apiError(500, "DETECT_ERROR", error?.message || "Failed to detect providers");
336
+ }
337
+ }
338
+ if (req.path === "/providers/postiz/integrations" && req.method === "GET") {
339
+ const postizCfg = getPostizConfig(req);
340
+ if (!postizCfg) return apiError(400, "NO_POSTIZ", "Postiz API key not configured");
341
+ try {
342
+ const res = await postizFetch(postizCfg, "/integrations");
343
+ const data = await res.json();
344
+ return { status: res.status, data };
345
+ } catch (error) {
346
+ return apiError(502, "POSTIZ_UNREACHABLE", error?.message || "Cannot reach Postiz");
347
+ }
348
+ }
349
+ if (req.path === "/publish" && req.method === "POST") {
350
+ const postizCfg = getPostizConfig(req);
351
+ if (!postizCfg) return apiError(400, "NO_POSTIZ", "Postiz API key not configured");
352
+ const body = req.body || {};
353
+ if (!body.content || !body.integrationIds?.length) {
354
+ return apiError(400, "VALIDATION_ERROR", "content and integrationIds are required");
355
+ }
356
+ try {
357
+ return await postizPublish(postizCfg, body);
358
+ } catch (error) {
359
+ return apiError(502, "POSTIZ_ERROR", error?.message || "Publish failed");
360
+ }
361
+ }
362
+ if (req.path === "/posts" && req.method === "GET") {
363
+ try {
364
+ const { db } = initializeDatabase(teamId);
365
+ const { limit, offset } = parsePagination(req.query);
366
+ const conditions = [(0, import_drizzle_orm2.eq)(posts.teamId, teamId)];
367
+ if (req.query.status) {
368
+ conditions.push((0, import_drizzle_orm2.eq)(posts.status, String(req.query.status)));
369
+ }
370
+ if (req.query.platform) {
371
+ conditions.push((0, import_drizzle_orm2.like)(posts.platforms, `%"${req.query.platform}"%`));
372
+ }
373
+ const totalResult = await db.select({ count: import_drizzle_orm2.sql`count(*)` }).from(posts).where((0, import_drizzle_orm2.and)(...conditions));
374
+ const total = totalResult[0]?.count ?? 0;
375
+ const posts2 = await db.select().from(posts).where((0, import_drizzle_orm2.and)(...conditions)).orderBy((0, import_drizzle_orm2.desc)(posts.createdAt)).limit(limit).offset(offset);
376
+ const transformed = posts2.map((post) => ({
377
+ id: post.id,
378
+ content: post.content,
379
+ platforms: JSON.parse(post.platforms || "[]"),
380
+ status: post.status,
381
+ scheduledAt: post.scheduledAt || void 0,
382
+ publishedAt: post.publishedAt || void 0,
383
+ tags: JSON.parse(post.tags || "[]"),
384
+ mediaIds: JSON.parse(post.mediaIds || "[]"),
385
+ templateId: post.templateId || void 0,
386
+ createdAt: post.createdAt,
387
+ updatedAt: post.updatedAt,
388
+ createdBy: post.createdBy
389
+ }));
390
+ const payload = {
391
+ data: transformed,
392
+ total,
393
+ offset,
394
+ limit,
395
+ hasMore: offset + limit < total
396
+ };
397
+ return { status: 200, data: payload };
398
+ } catch (error) {
399
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
400
+ }
401
+ }
402
+ if (req.path === "/posts" && req.method === "POST") {
403
+ try {
404
+ const userId = getUserId(req);
405
+ const body = req.body || {};
406
+ if (!body.content || !Array.isArray(body.platforms) || body.platforms.length === 0) {
407
+ return apiError(400, "VALIDATION_ERROR", "Content and platforms are required");
408
+ }
409
+ const { db } = initializeDatabase(teamId);
410
+ const now = (/* @__PURE__ */ new Date()).toISOString();
411
+ const newPost = {
412
+ id: (0, import_crypto2.randomUUID)(),
413
+ teamId,
414
+ content: body.content,
415
+ platforms: JSON.stringify(body.platforms),
416
+ status: body.status || "draft",
417
+ scheduledAt: body.scheduledAt || null,
418
+ publishedAt: null,
419
+ tags: JSON.stringify(body.tags || []),
420
+ mediaIds: JSON.stringify(body.mediaIds || []),
421
+ templateId: body.templateId || null,
422
+ createdAt: now,
423
+ updatedAt: now,
424
+ createdBy: userId
425
+ };
426
+ await db.insert(posts).values(newPost);
427
+ const response = {
428
+ id: newPost.id,
429
+ content: newPost.content,
430
+ platforms: JSON.parse(newPost.platforms),
431
+ status: newPost.status,
432
+ scheduledAt: newPost.scheduledAt || void 0,
433
+ publishedAt: void 0,
434
+ tags: JSON.parse(newPost.tags),
435
+ mediaIds: JSON.parse(newPost.mediaIds),
436
+ templateId: newPost.templateId || void 0,
437
+ createdAt: newPost.createdAt,
438
+ updatedAt: newPost.updatedAt,
439
+ createdBy: newPost.createdBy
440
+ };
441
+ return { status: 201, data: response };
442
+ } catch (error) {
443
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
444
+ }
445
+ }
446
+ if (req.path === "/accounts" && req.method === "GET") {
447
+ try {
448
+ const { db } = initializeDatabase(teamId);
449
+ const accounts = await db.select().from(socialAccounts).where((0, import_drizzle_orm2.eq)(socialAccounts.teamId, teamId)).orderBy((0, import_drizzle_orm2.desc)(socialAccounts.createdAt));
450
+ const response = accounts.map((account) => ({
451
+ id: account.id,
452
+ platform: account.platform,
453
+ displayName: account.displayName,
454
+ username: account.username || void 0,
455
+ avatar: account.avatar || void 0,
456
+ isActive: account.isActive,
457
+ settings: JSON.parse(account.settings || "{}"),
458
+ lastSync: account.lastSync || void 0,
459
+ createdAt: account.createdAt,
460
+ updatedAt: account.updatedAt
461
+ }));
462
+ return { status: 200, data: { accounts: response } };
463
+ } catch (error) {
464
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
465
+ }
466
+ }
467
+ if (req.path === "/accounts" && req.method === "POST") {
468
+ try {
469
+ const body = req.body || {};
470
+ if (!body.platform || !body.displayName || !body.credentials) {
471
+ return apiError(400, "VALIDATION_ERROR", "platform, displayName, and credentials are required");
472
+ }
473
+ const { db } = initializeDatabase(teamId);
474
+ const now = (/* @__PURE__ */ new Date()).toISOString();
475
+ const newAccount = {
476
+ id: (0, import_crypto2.randomUUID)(),
477
+ teamId,
478
+ platform: body.platform,
479
+ displayName: body.displayName,
480
+ username: body.username || null,
481
+ avatar: null,
482
+ isActive: true,
483
+ credentials: encryptCredentials(body.credentials),
484
+ settings: JSON.stringify(body.settings || {}),
485
+ lastSync: null,
486
+ createdAt: now,
487
+ updatedAt: now
488
+ };
489
+ await db.insert(socialAccounts).values(newAccount);
490
+ const response = {
491
+ id: newAccount.id,
492
+ platform: newAccount.platform,
493
+ displayName: newAccount.displayName,
494
+ username: newAccount.username || void 0,
495
+ isActive: newAccount.isActive,
496
+ settings: JSON.parse(newAccount.settings),
497
+ createdAt: newAccount.createdAt,
498
+ updatedAt: newAccount.updatedAt
499
+ };
500
+ return { status: 201, data: response };
501
+ } catch (error) {
502
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
503
+ }
504
+ }
505
+ return apiError(501, "NOT_IMPLEMENTED", `No handler for ${req.method} ${req.path}`);
506
+ }
507
+ // Annotate the CommonJS export names for ESM import in node:
508
+ 0 && (module.exports = {
509
+ handleRequest
510
+ });
@@ -4,6 +4,9 @@
4
4
  const R = window.React;
5
5
  if (!R) return;
6
6
  const h = R.createElement;
7
+ const useEffect = R.useEffect;
8
+ const useMemo = R.useMemo;
9
+ const useState = R.useState;
7
10
  const t = {
8
11
  text: { color: "var(--ck-text-primary)" },
9
12
  muted: { color: "var(--ck-text-secondary)" },
@@ -14,48 +17,406 @@
14
17
  borderRadius: "10px",
15
18
  padding: "1rem"
16
19
  },
17
- platformBtn: {
20
+ input: {
18
21
  background: "rgba(255,255,255,0.03)",
19
22
  border: "1px solid var(--ck-border-subtle)",
20
23
  borderRadius: "10px",
24
+ padding: "0.6rem 0.75rem",
25
+ color: "var(--ck-text-primary)",
26
+ width: "100%"
27
+ },
28
+ btnPrimary: {
29
+ background: "var(--ck-accent-red)",
30
+ border: "1px solid rgba(255,255,255,0.08)",
31
+ borderRadius: "10px",
32
+ padding: "0.5rem 0.75rem",
33
+ color: "white",
34
+ fontWeight: 700,
21
35
  cursor: "pointer",
22
- transition: "border-color 0.15s, background 0.15s",
23
- padding: "1rem",
24
- textAlign: "center"
25
- }
36
+ fontSize: "0.8rem"
37
+ },
38
+ btnGhost: {
39
+ background: "rgba(255,255,255,0.03)",
40
+ border: "1px solid var(--ck-border-subtle)",
41
+ borderRadius: "10px",
42
+ padding: "0.5rem 0.75rem",
43
+ color: "var(--ck-text-primary)",
44
+ fontWeight: 600,
45
+ cursor: "pointer",
46
+ fontSize: "0.8rem"
47
+ },
48
+ badge: (color) => ({
49
+ display: "inline-block",
50
+ background: color,
51
+ borderRadius: "999px",
52
+ padding: "0.15rem 0.5rem",
53
+ fontSize: "0.7rem",
54
+ fontWeight: 600,
55
+ color: "white"
56
+ })
57
+ };
58
+ const PLATFORM_ICONS = {
59
+ x: "\u{1D54F}",
60
+ twitter: "\u{1D54F}",
61
+ instagram: "\u{1F4F7}",
62
+ linkedin: "\u{1F4BC}",
63
+ facebook: "\u{1F4D8}",
64
+ youtube: "\u25B6\uFE0F",
65
+ tiktok: "\u{1F3B5}",
66
+ bluesky: "\u{1F98B}",
67
+ mastodon: "\u{1F418}",
68
+ reddit: "\u{1F916}",
69
+ discord: "\u{1F4AC}",
70
+ telegram: "\u2708\uFE0F",
71
+ pinterest: "\u{1F4CC}",
72
+ threads: "\u{1F9F5}",
73
+ medium: "\u270D\uFE0F",
74
+ wordpress: "\u{1F4DD}"
26
75
  };
27
- const platforms = [
28
- { icon: "\u{1D54F}", name: "Twitter / X" },
29
- { icon: "\u{1F4F7}", name: "Instagram" },
30
- { icon: "\u{1F3AC}", name: "YouTube" },
31
- { icon: "\u{1F4BC}", name: "LinkedIn" }
32
- ];
33
- function Accounts() {
76
+ const TYPE_COLORS = {
77
+ postiz: "rgba(99,179,237,0.7)",
78
+ gateway: "rgba(134,239,172,0.7)",
79
+ skill: "rgba(251,191,36,0.7)",
80
+ manual: "rgba(167,139,250,0.7)"
81
+ };
82
+ function Accounts(props) {
83
+ const teamId = String(props?.teamId || "default");
84
+ const apiBase = useMemo(() => `/api/plugins/marketing`, []);
85
+ const [providers, setProviders] = useState([]);
86
+ const [manualAccounts, setManualAccounts] = useState([]);
87
+ const [loading, setLoading] = useState(true);
88
+ const [detecting, setDetecting] = useState(false);
89
+ const [error, setError] = useState(null);
90
+ const [postizKey, setPostizKey] = useState("");
91
+ const [postizUrl, setPostizUrl] = useState("https://api.postiz.com/public/v1");
92
+ const [showPostizSetup, setShowPostizSetup] = useState(false);
93
+ const [showManual, setShowManual] = useState(false);
94
+ const [manPlatform, setManPlatform] = useState("twitter");
95
+ const [manName, setManName] = useState("");
96
+ const [manUser, setManUser] = useState("");
97
+ const [manToken, setManToken] = useState("");
98
+ const [saving, setSaving] = useState(false);
99
+ useEffect(() => {
100
+ try {
101
+ const stored = localStorage.getItem(`ck-postiz-${teamId}`);
102
+ if (stored) {
103
+ const parsed = JSON.parse(stored);
104
+ setPostizKey(parsed.apiKey || "");
105
+ setPostizUrl(parsed.baseUrl || "https://api.postiz.com/public/v1");
106
+ }
107
+ } catch {
108
+ }
109
+ }, [teamId]);
110
+ const savePostizConfig = () => {
111
+ try {
112
+ localStorage.setItem(`ck-postiz-${teamId}`, JSON.stringify({ apiKey: postizKey, baseUrl: postizUrl }));
113
+ } catch {
114
+ }
115
+ setShowPostizSetup(false);
116
+ void detectAll();
117
+ };
118
+ const detectAll = async () => {
119
+ setDetecting(true);
120
+ setError(null);
121
+ try {
122
+ const headers = {};
123
+ if (postizKey) {
124
+ headers["x-postiz-api-key"] = postizKey;
125
+ headers["x-postiz-base-url"] = postizUrl;
126
+ }
127
+ const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers });
128
+ const json = await res.json();
129
+ setProviders(Array.isArray(json.providers) ? json.providers : []);
130
+ } catch (e) {
131
+ setError(e?.message || "Failed to detect providers");
132
+ } finally {
133
+ setDetecting(false);
134
+ }
135
+ };
136
+ const loadManual = async () => {
137
+ try {
138
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`);
139
+ const json = await res.json();
140
+ setManualAccounts(Array.isArray(json.accounts) ? json.accounts : []);
141
+ } catch {
142
+ }
143
+ };
144
+ const refresh = async () => {
145
+ setLoading(true);
146
+ await Promise.all([detectAll(), loadManual()]);
147
+ setLoading(false);
148
+ };
149
+ useEffect(() => {
150
+ void refresh();
151
+ }, [teamId]);
152
+ const onManualConnect = async () => {
153
+ setSaving(true);
154
+ setError(null);
155
+ try {
156
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`, {
157
+ method: "POST",
158
+ headers: { "content-type": "application/json" },
159
+ body: JSON.stringify({
160
+ platform: manPlatform,
161
+ displayName: manName || `${manPlatform} account`,
162
+ username: manUser || void 0,
163
+ credentials: { accessToken: manToken }
164
+ })
165
+ });
166
+ if (!res.ok) throw new Error(`Failed (${res.status})`);
167
+ setShowManual(false);
168
+ setManName("");
169
+ setManUser("");
170
+ setManToken("");
171
+ await loadManual();
172
+ } catch (e) {
173
+ setError(e?.message || "Failed to connect");
174
+ } finally {
175
+ setSaving(false);
176
+ }
177
+ };
178
+ const allProviders = useMemo(() => {
179
+ const combined = [...providers];
180
+ for (const ma of manualAccounts) {
181
+ combined.push({
182
+ id: `manual:${ma.id}`,
183
+ type: "manual",
184
+ platform: ma.platform,
185
+ displayName: ma.displayName,
186
+ username: ma.username,
187
+ isActive: ma.isActive,
188
+ capabilities: ["post"]
189
+ });
190
+ }
191
+ return combined;
192
+ }, [providers, manualAccounts]);
193
+ const grouped = useMemo(() => {
194
+ const g = {};
195
+ for (const p of allProviders) {
196
+ const key = p.type;
197
+ if (!g[key]) g[key] = [];
198
+ g[key].push(p);
199
+ }
200
+ return g;
201
+ }, [allProviders]);
202
+ const typeLabels = {
203
+ postiz: "Postiz",
204
+ gateway: "OpenClaw Channels",
205
+ skill: "Skills",
206
+ manual: "Manual"
207
+ };
34
208
  return h(
35
209
  "div",
36
210
  { className: "space-y-3" },
211
+ // ---- Header ----
37
212
  h(
38
213
  "div",
39
214
  { style: t.card },
40
- h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Connect Account"),
41
215
  h(
42
216
  "div",
43
- { style: { display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: "0.5rem" } },
44
- ...platforms.map(
45
- (p) => h(
217
+ { className: "flex items-start justify-between gap-2" },
218
+ h(
219
+ "div",
220
+ null,
221
+ h("div", { className: "text-sm font-medium", style: t.text }, "Connected Accounts"),
222
+ h(
46
223
  "div",
47
- { key: p.name, style: t.platformBtn },
48
- h("div", { className: "text-xl mb-1" }, p.icon),
49
- h("div", { className: "text-xs font-medium", style: t.text }, p.name)
224
+ { className: "mt-1 text-xs", style: t.faint },
225
+ `${allProviders.length} provider${allProviders.length !== 1 ? "s" : ""} detected`
50
226
  )
227
+ ),
228
+ h(
229
+ "div",
230
+ { className: "flex flex-wrap gap-2" },
231
+ h(
232
+ "button",
233
+ { type: "button", onClick: () => void refresh(), style: t.btnGhost, disabled: detecting },
234
+ detecting ? "Detecting\u2026" : "\u21BB Refresh"
235
+ ),
236
+ h(
237
+ "button",
238
+ { type: "button", onClick: () => setShowPostizSetup(!showPostizSetup), style: t.btnGhost },
239
+ postizKey ? "\u2699 Postiz" : "+ Postiz"
240
+ ),
241
+ h("button", { type: "button", onClick: () => setShowManual(!showManual), style: t.btnGhost }, "+ Manual")
242
+ )
243
+ ),
244
+ error && h("div", { className: "mt-2 text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error)
245
+ ),
246
+ // ---- Postiz setup ----
247
+ showPostizSetup && h(
248
+ "div",
249
+ { style: t.card },
250
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Postiz Configuration"),
251
+ h(
252
+ "div",
253
+ { className: "text-xs mb-3", style: t.faint },
254
+ "Connect Postiz to manage social accounts via their platform. Get your API key from Postiz Settings \u2192 Developers \u2192 Public API."
255
+ ),
256
+ h(
257
+ "div",
258
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
259
+ h(
260
+ "div",
261
+ null,
262
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "API Key"),
263
+ h("input", {
264
+ type: "password",
265
+ value: postizKey,
266
+ onChange: (e) => setPostizKey(e.target.value),
267
+ placeholder: "your-postiz-api-key",
268
+ style: t.input
269
+ })
270
+ ),
271
+ h(
272
+ "div",
273
+ null,
274
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Base URL"),
275
+ h("input", {
276
+ value: postizUrl,
277
+ onChange: (e) => setPostizUrl(e.target.value),
278
+ placeholder: "https://api.postiz.com/public/v1",
279
+ style: t.input
280
+ })
51
281
  )
282
+ ),
283
+ h(
284
+ "div",
285
+ { className: "mt-3 flex gap-2" },
286
+ h("button", { type: "button", onClick: () => setShowPostizSetup(false), style: t.btnGhost }, "Cancel"),
287
+ h("button", { type: "button", onClick: savePostizConfig, style: t.btnPrimary }, postizKey ? "Save & Detect" : "Save")
52
288
  )
53
289
  ),
54
- h(
290
+ // ---- Manual account form ----
291
+ showManual && h(
55
292
  "div",
56
293
  { style: t.card },
57
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Connected Accounts"),
58
- h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No accounts connected yet.")
294
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Add manual account"),
295
+ h("div", { className: "text-xs mb-3", style: t.faint }, "For direct API access without Postiz. You provide the token."),
296
+ h(
297
+ "div",
298
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
299
+ h(
300
+ "div",
301
+ null,
302
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Platform"),
303
+ h(
304
+ "select",
305
+ { value: manPlatform, onChange: (e) => setManPlatform(e.target.value), style: t.input },
306
+ h("option", { value: "twitter" }, "Twitter / X"),
307
+ h("option", { value: "instagram" }, "Instagram"),
308
+ h("option", { value: "linkedin" }, "LinkedIn"),
309
+ h("option", { value: "bluesky" }, "Bluesky"),
310
+ h("option", { value: "mastodon" }, "Mastodon")
311
+ )
312
+ ),
313
+ h(
314
+ "div",
315
+ null,
316
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Display name"),
317
+ h("input", { value: manName, onChange: (e) => setManName(e.target.value), placeholder: "My X account", style: t.input })
318
+ ),
319
+ h(
320
+ "div",
321
+ null,
322
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Username"),
323
+ h("input", { value: manUser, onChange: (e) => setManUser(e.target.value), placeholder: "@handle", style: t.input })
324
+ ),
325
+ h(
326
+ "div",
327
+ null,
328
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Access token"),
329
+ h("input", { type: "password", value: manToken, onChange: (e) => setManToken(e.target.value), placeholder: "token\u2026", style: t.input })
330
+ )
331
+ ),
332
+ h(
333
+ "div",
334
+ { className: "mt-3 flex gap-2" },
335
+ h("button", { type: "button", onClick: () => setShowManual(false), style: t.btnGhost }, "Cancel"),
336
+ h("button", { type: "button", onClick: () => void onManualConnect(), style: t.btnPrimary, disabled: saving }, saving ? "Saving\u2026" : "Connect")
337
+ )
338
+ ),
339
+ // ---- Loading ----
340
+ loading && h(
341
+ "div",
342
+ { style: t.card },
343
+ h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Detecting providers\u2026")
344
+ ),
345
+ // ---- Provider groups ----
346
+ !loading && allProviders.length === 0 && h(
347
+ "div",
348
+ { style: t.card },
349
+ h(
350
+ "div",
351
+ { className: "py-6 text-center space-y-2" },
352
+ h("div", { className: "text-sm", style: t.faint }, "No providers detected"),
353
+ h(
354
+ "div",
355
+ { className: "text-xs", style: t.faint },
356
+ "Connect Postiz for full social media management, or add accounts manually."
357
+ )
358
+ )
359
+ ),
360
+ !loading && Object.entries(grouped).map(
361
+ ([type, items]) => h(
362
+ "div",
363
+ { key: type, style: t.card },
364
+ h(
365
+ "div",
366
+ { className: "flex items-center gap-2 mb-3" },
367
+ h("div", { className: "text-sm font-medium", style: t.text }, typeLabels[type] || type),
368
+ h("span", { style: t.badge(TYPE_COLORS[type] || "rgba(100,100,100,0.6)") }, `${items.length}`)
369
+ ),
370
+ h(
371
+ "div",
372
+ { className: "space-y-2" },
373
+ ...items.map(
374
+ (p) => h(
375
+ "div",
376
+ { key: p.id, style: { ...t.card, padding: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem" } },
377
+ // Avatar or platform icon
378
+ p.avatar ? h("img", { src: p.avatar, alt: "", style: { width: 32, height: 32, borderRadius: "50%", objectFit: "cover" } }) : h("div", {
379
+ style: {
380
+ width: 32,
381
+ height: 32,
382
+ borderRadius: "50%",
383
+ background: "rgba(255,255,255,0.06)",
384
+ display: "flex",
385
+ alignItems: "center",
386
+ justifyContent: "center",
387
+ fontSize: "1rem"
388
+ }
389
+ }, PLATFORM_ICONS[p.platform] || "\u{1F517}"),
390
+ // Info
391
+ h(
392
+ "div",
393
+ { style: { flex: 1, minWidth: 0 } },
394
+ h("div", { className: "text-sm font-medium", style: t.text }, p.displayName),
395
+ h(
396
+ "div",
397
+ { className: "text-xs", style: t.faint },
398
+ [p.platform, p.username].filter(Boolean).join(" \xB7 ")
399
+ )
400
+ ),
401
+ // Status + capabilities
402
+ h(
403
+ "div",
404
+ { className: "flex items-center gap-2 shrink-0" },
405
+ p.capabilities?.includes("schedule") && h("span", { className: "text-xs", style: t.faint }, "\u23F1"),
406
+ p.capabilities?.includes("post") && h("span", { className: "text-xs", style: t.faint }, "\u{1F4E4}"),
407
+ h("div", {
408
+ style: {
409
+ width: 8,
410
+ height: 8,
411
+ borderRadius: "50%",
412
+ background: p.isActive ? "rgba(74,222,128,0.8)" : "rgba(248,113,113,0.6)"
413
+ }
414
+ })
415
+ )
416
+ )
417
+ )
418
+ )
419
+ )
59
420
  )
60
421
  );
61
422
  }
@@ -4,6 +4,10 @@
4
4
  const R = window.React;
5
5
  if (!R) return;
6
6
  const h = R.createElement;
7
+ const useEffect = R.useEffect;
8
+ const useMemo = R.useMemo;
9
+ const useState = R.useState;
10
+ const useCallback = R.useCallback;
7
11
  const t = {
8
12
  text: { color: "var(--ck-text-primary)" },
9
13
  muted: { color: "var(--ck-text-secondary)" },
@@ -13,31 +17,331 @@
13
17
  border: "1px solid var(--ck-border-subtle)",
14
18
  borderRadius: "10px",
15
19
  padding: "1rem"
20
+ },
21
+ input: {
22
+ background: "rgba(255,255,255,0.03)",
23
+ border: "1px solid var(--ck-border-subtle)",
24
+ borderRadius: "10px",
25
+ padding: "0.6rem 0.75rem",
26
+ color: "var(--ck-text-primary)",
27
+ width: "100%"
28
+ },
29
+ btnPrimary: {
30
+ background: "var(--ck-accent-red)",
31
+ border: "1px solid rgba(255,255,255,0.08)",
32
+ borderRadius: "10px",
33
+ padding: "0.6rem 0.85rem",
34
+ color: "white",
35
+ fontWeight: 700,
36
+ cursor: "pointer"
37
+ },
38
+ btnGhost: {
39
+ background: "rgba(255,255,255,0.03)",
40
+ border: "1px solid var(--ck-border-subtle)",
41
+ borderRadius: "10px",
42
+ padding: "0.6rem 0.85rem",
43
+ color: "var(--ck-text-primary)",
44
+ fontWeight: 600,
45
+ cursor: "pointer"
46
+ },
47
+ btnPublish: {
48
+ background: "rgba(99,179,237,0.2)",
49
+ border: "1px solid rgba(99,179,237,0.4)",
50
+ borderRadius: "10px",
51
+ padding: "0.6rem 0.85rem",
52
+ color: "rgba(210,235,255,0.95)",
53
+ fontWeight: 700,
54
+ cursor: "pointer"
55
+ },
56
+ pill: (active) => ({
57
+ background: active ? "rgba(99,179,237,0.16)" : "rgba(255,255,255,0.03)",
58
+ border: `1px solid ${active ? "rgba(99,179,237,0.45)" : "var(--ck-border-subtle)"}`,
59
+ borderRadius: "999px",
60
+ padding: "0.25rem 0.55rem",
61
+ fontSize: "0.8rem",
62
+ color: active ? "rgba(210,235,255,0.95)" : "var(--ck-text-secondary)",
63
+ cursor: "pointer",
64
+ userSelect: "none"
65
+ }),
66
+ statusBadge: (status) => {
67
+ const colors = {
68
+ draft: "rgba(167,139,250,0.7)",
69
+ scheduled: "rgba(251,191,36,0.7)",
70
+ published: "rgba(74,222,128,0.7)",
71
+ failed: "rgba(248,113,113,0.7)"
72
+ };
73
+ return {
74
+ display: "inline-block",
75
+ background: colors[status] || "rgba(100,100,100,0.5)",
76
+ borderRadius: "999px",
77
+ padding: "0.1rem 0.45rem",
78
+ fontSize: "0.7rem",
79
+ fontWeight: 600,
80
+ color: "white"
81
+ };
16
82
  }
17
83
  };
18
- function ContentLibrary() {
84
+ function ContentLibrary(props) {
85
+ const teamId = String(props?.teamId || "default");
86
+ const apiBase = useMemo(() => `/api/plugins/marketing`, []);
87
+ const [posts, setPosts] = useState([]);
88
+ const [providers, setProviders] = useState([]);
89
+ const [loading, setLoading] = useState(true);
90
+ const [saving, setSaving] = useState(false);
91
+ const [publishing, setPublishing] = useState(false);
92
+ const [error, setError] = useState(null);
93
+ const [success, setSuccess] = useState(null);
94
+ const [content, setContent] = useState("");
95
+ const [selectedProviders, setSelectedProviders] = useState([]);
96
+ const [scheduledAt, setScheduledAt] = useState("");
97
+ const postizHeaders = useMemo(() => {
98
+ try {
99
+ const stored = localStorage.getItem(`ck-postiz-${teamId}`);
100
+ if (stored) {
101
+ const parsed = JSON.parse(stored);
102
+ if (parsed.apiKey) {
103
+ return {
104
+ "x-postiz-api-key": parsed.apiKey,
105
+ "x-postiz-base-url": parsed.baseUrl || "https://api.postiz.com/public/v1"
106
+ };
107
+ }
108
+ }
109
+ } catch {
110
+ }
111
+ return {};
112
+ }, [teamId]);
113
+ const loadPosts = useCallback(async () => {
114
+ try {
115
+ const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}&limit=25`);
116
+ const json = await res.json();
117
+ setPosts(Array.isArray(json.data) ? json.data : []);
118
+ } catch {
119
+ }
120
+ }, [apiBase, teamId]);
121
+ const loadProviders = useCallback(async () => {
122
+ try {
123
+ const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers: postizHeaders });
124
+ const json = await res.json();
125
+ const detected = Array.isArray(json.providers) ? json.providers : [];
126
+ setProviders(detected);
127
+ } catch {
128
+ }
129
+ }, [apiBase, teamId, postizHeaders]);
130
+ useEffect(() => {
131
+ setLoading(true);
132
+ Promise.all([loadPosts(), loadProviders()]).finally(() => setLoading(false));
133
+ }, [loadPosts, loadProviders]);
134
+ const toggleProvider = (id) => {
135
+ setSelectedProviders(
136
+ (prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
137
+ );
138
+ };
139
+ const onSaveDraft = async () => {
140
+ if (!content.trim()) return;
141
+ setSaving(true);
142
+ setError(null);
143
+ try {
144
+ const platforms = selectedProviders.map((id) => providers.find((p) => p.id === id)?.platform).filter(Boolean);
145
+ const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}`, {
146
+ method: "POST",
147
+ headers: { "content-type": "application/json" },
148
+ body: JSON.stringify({
149
+ content,
150
+ platforms: platforms.length > 0 ? platforms : ["draft"],
151
+ status: scheduledAt ? "scheduled" : "draft",
152
+ scheduledAt: scheduledAt || void 0
153
+ })
154
+ });
155
+ if (!res.ok) throw new Error(`Save failed (${res.status})`);
156
+ setContent("");
157
+ setScheduledAt("");
158
+ setSelectedProviders([]);
159
+ await loadPosts();
160
+ } catch (e) {
161
+ setError(e?.message || "Failed to save");
162
+ } finally {
163
+ setSaving(false);
164
+ }
165
+ };
166
+ const onPublish = async () => {
167
+ if (!content.trim() || selectedProviders.length === 0) return;
168
+ setPublishing(true);
169
+ setError(null);
170
+ setSuccess(null);
171
+ const postizProviders = selectedProviders.filter((id) => id.startsWith("postiz:"));
172
+ const gatewayProviders = selectedProviders.filter((id) => id.startsWith("gateway:"));
173
+ try {
174
+ if (postizProviders.length > 0) {
175
+ const integrationIds = postizProviders.map((id) => {
176
+ const prov = providers.find((p) => p.id === id);
177
+ return prov?.meta?.postizId;
178
+ }).filter(Boolean);
179
+ const res = await fetch(`${apiBase}/publish?team=${encodeURIComponent(teamId)}`, {
180
+ method: "POST",
181
+ headers: { "content-type": "application/json", ...postizHeaders },
182
+ body: JSON.stringify({
183
+ content,
184
+ integrationIds,
185
+ scheduledAt: scheduledAt || void 0
186
+ })
187
+ });
188
+ if (!res.ok) {
189
+ const err = await res.json().catch(() => null);
190
+ throw new Error(err?.message || `Postiz publish failed (${res.status})`);
191
+ }
192
+ }
193
+ if (gatewayProviders.length > 0 && postizProviders.length === 0) {
194
+ setSuccess("Saved! Gateway posting requires OpenClaw agent \u2014 use the workflow or ask your assistant to post.");
195
+ } else {
196
+ setSuccess(scheduledAt ? "Scheduled via Postiz!" : "Published via Postiz!");
197
+ }
198
+ setContent("");
199
+ setScheduledAt("");
200
+ setSelectedProviders([]);
201
+ await loadPosts();
202
+ } catch (e) {
203
+ setError(e?.message || "Publish failed");
204
+ } finally {
205
+ setPublishing(false);
206
+ }
207
+ };
208
+ const postizAvailable = providers.some((p) => p.type === "postiz");
209
+ const hasSelection = selectedProviders.length > 0;
19
210
  return h(
20
211
  "div",
21
212
  { className: "space-y-3" },
213
+ // ---- Composer ----
22
214
  h(
23
215
  "div",
24
216
  { style: t.card },
25
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Create New Post"),
26
- h("div", { className: "text-sm", style: t.muted }, "Your content creation tools will live here:"),
217
+ h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Compose"),
27
218
  h(
28
- "ul",
29
- { className: "text-sm mt-2 space-y-1", style: { ...t.muted, listStyle: "disc inside", paddingLeft: "0.5rem" } },
30
- h("li", null, "Rich text editor with media embedding"),
31
- h("li", null, "Multi-platform publishing (Twitter, Instagram, LinkedIn)"),
32
- h("li", null, "Scheduling & auto-posting"),
33
- h("li", null, "Template library for quick starts")
219
+ "div",
220
+ { className: "space-y-3" },
221
+ h("textarea", {
222
+ value: content,
223
+ onChange: (e) => setContent(e.target.value),
224
+ placeholder: "Write your post\u2026",
225
+ rows: 5,
226
+ style: { ...t.input, resize: "vertical", minHeight: "110px" }
227
+ }),
228
+ // Provider selector
229
+ providers.length > 0 && h(
230
+ "div",
231
+ null,
232
+ h("div", { className: "text-xs font-medium mb-2", style: t.faint }, "Publish to"),
233
+ h(
234
+ "div",
235
+ { className: "flex flex-wrap gap-2" },
236
+ ...providers.filter((p) => p.isActive).map(
237
+ (p) => h(
238
+ "span",
239
+ {
240
+ key: p.id,
241
+ onClick: () => toggleProvider(p.id),
242
+ style: t.pill(selectedProviders.includes(p.id)),
243
+ role: "button",
244
+ tabIndex: 0
245
+ },
246
+ `${p.displayName}`
247
+ )
248
+ )
249
+ )
250
+ ),
251
+ // No providers hint
252
+ providers.length === 0 && !loading && h(
253
+ "div",
254
+ { className: "text-xs", style: t.faint },
255
+ "No publishing targets detected. Go to Accounts tab to connect Postiz or add accounts."
256
+ ),
257
+ // Schedule
258
+ h(
259
+ "div",
260
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
261
+ h(
262
+ "div",
263
+ null,
264
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Schedule (optional)"),
265
+ h("input", {
266
+ type: "datetime-local",
267
+ value: scheduledAt,
268
+ onChange: (e) => setScheduledAt(e.target.value),
269
+ style: t.input
270
+ })
271
+ ),
272
+ h(
273
+ "div",
274
+ { className: "flex items-end" },
275
+ h(
276
+ "div",
277
+ { className: "text-xs", style: t.faint },
278
+ content.length > 0 ? `${content.length} chars` : ""
279
+ )
280
+ )
281
+ ),
282
+ // Actions
283
+ h(
284
+ "div",
285
+ { className: "flex flex-wrap gap-2 items-center" },
286
+ h("button", {
287
+ type: "button",
288
+ onClick: () => void onSaveDraft(),
289
+ style: { ...t.btnGhost, opacity: saving ? 0.7 : 1 },
290
+ disabled: saving || !content.trim()
291
+ }, saving ? "Saving\u2026" : "Save draft"),
292
+ postizAvailable && hasSelection && h("button", {
293
+ type: "button",
294
+ onClick: () => void onPublish(),
295
+ style: { ...t.btnPublish, opacity: publishing ? 0.7 : 1 },
296
+ disabled: publishing || !content.trim()
297
+ }, publishing ? "Publishing\u2026" : scheduledAt ? "\u23F1 Schedule" : "\u{1F4E4} Publish"),
298
+ !postizAvailable && hasSelection && h(
299
+ "div",
300
+ { className: "text-xs", style: t.faint },
301
+ "Connect Postiz on Accounts tab to publish directly."
302
+ )
303
+ ),
304
+ error && h("div", { className: "text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error),
305
+ success && h("div", { className: "text-xs", style: { color: "rgba(74,222,128,0.9)" } }, success)
34
306
  )
35
307
  ),
308
+ // ---- Posts list ----
36
309
  h(
37
310
  "div",
38
311
  { style: t.card },
39
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Recent Posts"),
40
- h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No posts yet \u2014 create your first post to get started.")
312
+ h(
313
+ "div",
314
+ { className: "flex items-center justify-between mb-2" },
315
+ h("div", { className: "text-sm font-medium", style: t.text }, "Posts"),
316
+ h("button", { type: "button", onClick: () => void loadPosts(), style: t.btnGhost, className: "text-xs" }, "\u21BB")
317
+ ),
318
+ loading ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Loading\u2026") : posts.length === 0 ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No posts yet.") : h(
319
+ "div",
320
+ { className: "space-y-2" },
321
+ ...posts.map(
322
+ (p) => h(
323
+ "div",
324
+ { key: p.id, style: { ...t.card, padding: "0.75rem" } },
325
+ h(
326
+ "div",
327
+ { className: "flex items-center justify-between gap-2" },
328
+ h(
329
+ "div",
330
+ { className: "flex items-center gap-2" },
331
+ h("span", { style: t.statusBadge(p.status) }, p.status),
332
+ h("span", { className: "text-xs", style: t.faint }, new Date(p.createdAt).toLocaleString())
333
+ ),
334
+ p.scheduledAt && h("div", { className: "text-xs", style: t.muted }, `\u23F1 ${new Date(p.scheduledAt).toLocaleString()}`)
335
+ ),
336
+ h("div", { className: "mt-2 whitespace-pre-wrap text-sm", style: t.text }, p.content),
337
+ p.platforms?.length > 0 && h(
338
+ "div",
339
+ { className: "mt-2 flex flex-wrap gap-1" },
340
+ ...p.platforms.map((pl) => h("span", { key: pl, style: t.pill(true) }, pl))
341
+ )
342
+ )
343
+ )
344
+ )
41
345
  )
42
346
  );
43
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/kitchen-plugin-marketing",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Marketing Suite plugin for ClawKitchen",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -45,7 +45,7 @@
45
45
  "bundle": "./dist/tabs/accounts.js"
46
46
  }
47
47
  ],
48
- "apiRoutes": "./dist/api/routes.js",
48
+ "apiRoutes": "./dist/api/handler.js",
49
49
  "migrations": "./db/migrations"
50
50
  },
51
51
  "scripts": {