ofiere-openclaw-plugin 4.15.0 → 4.17.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 CHANGED
@@ -5,7 +5,7 @@ Manage your Ofiere PM dashboard directly from OpenClaw agents. Create tasks, man
5
5
  ## Quick Install (One-Click)
6
6
 
7
7
  ```bash
8
- curl -sSL https://raw.githubusercontent.com/gilanggemar/Ofiere/main/ofiere-openclaw-plugin/install.sh | bash -s -- \
8
+ curl -sSL https://ofiere.com/scripts/install.sh | bash -s -- \
9
9
  --supabase-url "https://xxx.supabase.co" \
10
10
  --service-key "eyJ..." \
11
11
  --user-id "your-uuid"
@@ -16,7 +16,7 @@ Only 3 parameters needed. All agents get the plugin automatically.
16
16
  ## Uninstall
17
17
 
18
18
  ```bash
19
- curl -sSL https://raw.githubusercontent.com/gilanggemar/Ofiere/main/ofiere-openclaw-plugin/uninstall.sh | bash
19
+ curl -sSL https://ofiere.com/scripts/uninstall.sh | bash
20
20
  ```
21
21
 
22
22
  ## How It Works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofiere-openclaw-plugin",
3
- "version": "4.15.0",
3
+ "version": "4.17.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin for Ofiere PM - 11 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, and space file management",
6
6
  "keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
package/src/prompt.ts CHANGED
@@ -62,10 +62,19 @@ const TOOL_DOCS: Record<string, string> = {
62
62
  - Edge handles: condition edges use sourceHandle "condition-true"/"condition-false". Loop edges use "loop_body"/"done"
63
63
  - Variables: Use {{prev.nodeId.outputText}} for prior outputs, {{variables.key}} for stored variables`,
64
64
 
65
- OFIERE_NOTIFY_OPS: `- **OFIERE_NOTIFY_OPS** — Notifications (action: "list", "mark_read", "mark_all_read", "delete")
65
+ OFIERE_NOTIFY_OPS: `- **OFIERE_NOTIFY_OPS** — Notifications & Channel Reports (action: "list", "mark_read", "mark_all_read", "delete", "send_report", "schedule_report", "list_schedules", "delete_schedule")
66
66
  - list: Recent notifications. unread_only=true for unread only
67
67
  - mark_read: Mark one notification read by ID
68
- - mark_all_read: Mark all as read`,
68
+ - mark_all_read: Mark all as read
69
+ - send_report: Send a PM progress report to YOUR connected channels (Telegram, Discord, Slack, etc.)
70
+ - Required: scope_type (space|folder|project|task|all)
71
+ - Optional: scope_id, channel_types[] (filter to specific channels), include_completed
72
+ - The report is automatically generated from current PM data and sent through YOUR channel bindings ONLY
73
+ - schedule_report: Create a recurring/scheduled report
74
+ - Required: scope_type, recurrence_type (hourly|daily|weekly|monthly)
75
+ - Optional: scope_id, scope_label, channel_types[], recurrence_time (HH:MM UTC, default 09:00), recurrence_interval, recurrence_days_of_week (e.g. "mon,wed,fri"), include_completed
76
+ - list_schedules: View all your active report schedules
77
+ - delete_schedule: Remove a scheduled report by schedule_id`,
69
78
 
70
79
  OFIERE_MEMORY_OPS: `- **OFIERE_MEMORY_OPS** — Conversation history & knowledge memory (action: "list_conversations", "get_messages", "search_messages", "add_knowledge", "search_knowledge")
71
80
  - list_conversations: Recent chats, filter by agent_id
@@ -165,6 +174,9 @@ ${toolDocs}
165
174
  - When task instructions or system prompts contain file references like @[filename](file:FILE_ID), use OFIERE_FILE_OPS action:"read_text_file" file_id:"FILE_ID" to read the file content. Do NOT ask the user for the file — retrieve it yourself.
166
175
  - Use OFIERE_FILE_OPS to create output files (reports, data, configs) in the Space Files explorer. Prefer create_text_file for text-based outputs.
167
176
  - To save task output as a file, call OFIERE_FILE_OPS action:"create_text_file" with the space_id from the task context.
177
+ - CHANNEL REPORTS: When the user asks you to "send a report", "send progress", "update me on Telegram/Discord/Slack/WhatsApp", use OFIERE_NOTIFY_OPS action:"send_report". The report is generated from live PM data and sent through YOUR connected channels ONLY — not other agents' channels.
178
+ - To set up recurring reports (e.g. "send me a daily report at 9am"), use OFIERE_NOTIFY_OPS action:"schedule_report" with scope_type and recurrence_type.
179
+ - If a report is too long for the channel's message limit, save the full report as a markdown file using OFIERE_FILE_OPS action:"create_text_file" and send a summary to the channel instead.
168
180
  </ofiere-pm>`;
169
181
  }
170
182
 
package/src/tools.ts CHANGED
@@ -2144,24 +2144,51 @@ function registerNotifyOps(
2144
2144
  supabase: SupabaseClient,
2145
2145
  userId: string,
2146
2146
  ): void {
2147
+ // Helper: resolve the agent's own ID for agent-scoped operations
2148
+ const resolveAgentId = (): string => {
2149
+ try {
2150
+ const state = api.getState?.() || {};
2151
+ return state.agentId || process.env.OFIERE_AGENT_ID || "";
2152
+ } catch { return process.env.OFIERE_AGENT_ID || ""; }
2153
+ };
2154
+
2147
2155
  api.registerTool({
2148
2156
  name: "OFIERE_NOTIFY_OPS",
2149
- label: "Ofiere Notification Operations",
2157
+ label: "Ofiere Notification & Report Operations",
2150
2158
  description:
2151
- `Read and manage notifications.\n\n` +
2159
+ `Read/manage notifications and send PM reports to your connected channels.\n\n` +
2152
2160
  `Actions:\n` +
2153
2161
  `- "list": List notifications. Optional: unread_only (true/false), limit\n` +
2154
2162
  `- "mark_read": Mark one as read. Required: id\n` +
2155
2163
  `- "mark_all_read": Mark all as read\n` +
2156
- `- "delete": Delete a notification. Required: id`,
2164
+ `- "delete": Delete a notification. Required: id\n` +
2165
+ `- "send_report": Send a PM progress report to YOUR connected channels (Telegram, Discord, etc.)\n` +
2166
+ ` Required: scope_type (space|folder|project|task|all)\n` +
2167
+ ` Optional: scope_id, channel_types[] (filter specific channels), include_completed (default true)\n` +
2168
+ `- "schedule_report": Create a recurring/scheduled report\n` +
2169
+ ` Required: scope_type, recurrence_type (hourly|daily|weekly|monthly)\n` +
2170
+ ` Optional: scope_id, scope_label, channel_types[], recurrence_time (HH:MM UTC, default 09:00),\n` +
2171
+ ` recurrence_interval (default 1), recurrence_days_of_week (e.g. "mon,wed,fri"), include_completed\n` +
2172
+ `- "list_schedules": List your active report schedules\n` +
2173
+ `- "delete_schedule": Delete a scheduled report. Required: schedule_id`,
2157
2174
  parameters: {
2158
2175
  type: "object",
2159
2176
  required: ["action"],
2160
2177
  properties: {
2161
- action: { type: "string", enum: ["list", "mark_read", "mark_all_read", "delete"] },
2178
+ action: { type: "string", enum: ["list", "mark_read", "mark_all_read", "delete", "send_report", "schedule_report", "list_schedules", "delete_schedule"] },
2162
2179
  id: { type: "string", description: "Notification ID" },
2180
+ schedule_id: { type: "string", description: "Schedule ID (for delete_schedule)" },
2163
2181
  unread_only: { type: "boolean", description: "Only show unread" },
2164
2182
  limit: { type: "number", description: "Max results (default 50)" },
2183
+ scope_type: { type: "string", enum: ["space", "folder", "project", "task", "all"], description: "Report scope" },
2184
+ scope_id: { type: "string", description: "ID of the space/folder/project/task" },
2185
+ scope_label: { type: "string", description: "Human-readable label for the scope" },
2186
+ channel_types: { type: "array", items: { type: "string" }, description: "Filter to specific channels (e.g. ['telegram'])" },
2187
+ include_completed: { type: "boolean", description: "Include completed tasks in report" },
2188
+ recurrence_type: { type: "string", enum: ["hourly", "daily", "weekly", "monthly"], description: "How often to send" },
2189
+ recurrence_interval: { type: "number", description: "Interval multiplier (default 1)" },
2190
+ recurrence_time: { type: "string", description: "Time in HH:MM UTC (default 09:00)" },
2191
+ recurrence_days_of_week: { type: "string", description: "Comma-separated days for weekly (e.g. mon,wed,fri)" },
2165
2192
  },
2166
2193
  },
2167
2194
  async execute(_id: string, params: Record<string, unknown>) {
@@ -2194,13 +2221,267 @@ function registerNotifyOps(
2194
2221
  if (error) return err(error.message);
2195
2222
  return ok({ message: "Notification deleted", ok: true });
2196
2223
  }
2224
+
2225
+ // ── SEND REPORT — Dispatch PM report to agent's channels ──
2226
+ case "send_report": {
2227
+ const scopeType = (params.scope_type as string) || "all";
2228
+ const scopeId = params.scope_id as string | undefined;
2229
+ const channelTypes = (params.channel_types as string[]) || [];
2230
+ const includeCompleted = params.include_completed !== false;
2231
+ const agentId = resolveAgentId();
2232
+ if (!agentId) return err("Cannot resolve your agent ID. Ensure OFIERE_AGENT_ID is configured.");
2233
+
2234
+ // Call the dashboard API to generate + dispatch
2235
+ const supabaseUrl = (supabase as any).supabaseUrl || process.env.OFIERE_SUPABASE_URL || process.env.SUPABASE_URL || "";
2236
+ const serviceRoleKey = (supabase as any).supabaseKey || process.env.OFIERE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
2237
+
2238
+ // Generate report directly from DB (we have service-role access)
2239
+ const reportResult = await generatePMReportDirect(supabase, userId, scopeType, scopeId, agentId, includeCompleted);
2240
+
2241
+ // Dispatch to channels
2242
+ const delivery = await dispatchReportDirect(supabase, userId, agentId, reportResult.report, channelTypes);
2243
+
2244
+ // Create audit notification
2245
+ await supabase.from("notifications").insert({
2246
+ id: crypto.randomUUID(),
2247
+ user_id: userId,
2248
+ title: `Report sent: ${reportResult.scopeLabel}`,
2249
+ message: `Dispatched via ${delivery.sent.join(", ") || "no channels"}. ${reportResult.taskCount} tasks.`,
2250
+ type: "system",
2251
+ category: "project_management",
2252
+ read: false,
2253
+ });
2254
+
2255
+ return ok({
2256
+ message: `Report sent to ${delivery.sent.length} channel(s)`,
2257
+ channels_sent: delivery.sent,
2258
+ channels_failed: delivery.failed,
2259
+ task_count: reportResult.taskCount,
2260
+ scope: reportResult.scopeLabel,
2261
+ report_preview: reportResult.report.slice(0, 500),
2262
+ });
2263
+ }
2264
+
2265
+ // ── SCHEDULE REPORT — Create recurring report ──
2266
+ case "schedule_report": {
2267
+ const scopeType = (params.scope_type as string) || "all";
2268
+ const recurrenceType = (params.recurrence_type as string) || "daily";
2269
+ const agentId = resolveAgentId();
2270
+ if (!agentId) return err("Cannot resolve your agent ID.");
2271
+
2272
+ const recurrenceTime = (params.recurrence_time as string) || "09:00";
2273
+ const recurrenceInterval = (params.recurrence_interval as number) || 1;
2274
+ const recurrenceDaysOfWeek = (params.recurrence_days_of_week as string) || null;
2275
+
2276
+ // Compute next run
2277
+ const [hours, minutes] = recurrenceTime.split(":").map(Number);
2278
+ const next = new Date();
2279
+ next.setUTCHours(hours, minutes, 0, 0);
2280
+ if (next.getTime() <= Date.now()) next.setUTCDate(next.getUTCDate() + 1);
2281
+
2282
+ const { data, error } = await supabase.from("pm_report_schedules").insert({
2283
+ user_id: userId,
2284
+ agent_id: agentId,
2285
+ scope_type: scopeType,
2286
+ scope_id: (params.scope_id as string) || null,
2287
+ scope_label: (params.scope_label as string) || null,
2288
+ channel_types: (params.channel_types as string[]) || [],
2289
+ recurrence_type: recurrenceType,
2290
+ recurrence_interval: recurrenceInterval,
2291
+ recurrence_time: recurrenceTime,
2292
+ recurrence_days_of_week: recurrenceDaysOfWeek,
2293
+ include_completed: params.include_completed !== false,
2294
+ next_run_at: next.toISOString(),
2295
+ }).select().single();
2296
+
2297
+ if (error) return err(error.message);
2298
+ return ok({
2299
+ message: `Scheduled ${recurrenceType} report created`,
2300
+ schedule_id: data.id,
2301
+ next_run: next.toISOString(),
2302
+ recurrence: `${recurrenceType} at ${recurrenceTime} UTC`,
2303
+ });
2304
+ }
2305
+
2306
+ // ── LIST SCHEDULES ──
2307
+ case "list_schedules": {
2308
+ const { data, error } = await supabase.from("pm_report_schedules")
2309
+ .select("*")
2310
+ .eq("user_id", userId)
2311
+ .order("created_at", { ascending: false });
2312
+ if (error) return err(error.message);
2313
+ return ok({ schedules: data || [], count: (data || []).length });
2314
+ }
2315
+
2316
+ // ── DELETE SCHEDULE ──
2317
+ case "delete_schedule": {
2318
+ const scheduleId = (params.schedule_id || params.id) as string;
2319
+ if (!scheduleId) return err("Missing required: schedule_id");
2320
+ const { error } = await supabase.from("pm_report_schedules")
2321
+ .delete().eq("id", scheduleId).eq("user_id", userId);
2322
+ if (error) return err(error.message);
2323
+ return ok({ message: "Schedule deleted", ok: true });
2324
+ }
2325
+
2197
2326
  default:
2198
- return err(`Unknown action "${action}".`);
2327
+ return err(`Unknown action "${action}". Valid: list, mark_read, mark_all_read, delete, send_report, schedule_report, list_schedules, delete_schedule`);
2199
2328
  }
2200
2329
  },
2201
2330
  });
2202
2331
  }
2203
2332
 
2333
+ // ── Inline report generation for plugin (avoids HTTP roundtrip) ──
2334
+
2335
+ async function generatePMReportDirect(
2336
+ supabase: SupabaseClient,
2337
+ userId: string,
2338
+ scopeType: string,
2339
+ scopeId: string | undefined,
2340
+ agentId: string,
2341
+ includeCompleted: boolean,
2342
+ ): Promise<{ report: string; scopeLabel: string; taskCount: number }> {
2343
+ let scopeLabel = "All Projects";
2344
+ let query = supabase.from("tasks").select("id, title, status, priority, progress, completed_at, due_date")
2345
+ .eq("user_id", userId).is("parent_task_id", null);
2346
+
2347
+ if (scopeType === "space" && scopeId) {
2348
+ query = query.eq("space_id", scopeId);
2349
+ const { data: s } = await supabase.from("pm_spaces").select("name").eq("id", scopeId).single();
2350
+ scopeLabel = s?.name || "Space";
2351
+ } else if (scopeType === "folder" && scopeId) {
2352
+ query = query.eq("folder_id", scopeId);
2353
+ const { data: f } = await supabase.from("pm_folders").select("name").eq("id", scopeId).single();
2354
+ scopeLabel = f?.name || "Folder";
2355
+ } else if (scopeType === "project" && scopeId) {
2356
+ query = query.eq("project_id", scopeId);
2357
+ const { data: p } = await supabase.from("pm_folders").select("name").eq("id", scopeId).single();
2358
+ scopeLabel = p?.name || "Project";
2359
+ }
2360
+
2361
+ const { data: tasks } = await query.order("priority", { ascending: false }).limit(40);
2362
+ const all = tasks || [];
2363
+
2364
+ // Agent name
2365
+ let agentName = agentId;
2366
+ const { data: agentRow } = await supabase.from("agents").select("name, codename")
2367
+ .eq("user_id", userId)
2368
+ .or(`id.eq.${agentId},name.ilike.${agentId},codename.ilike.${agentId}`)
2369
+ .limit(1).single();
2370
+ if (agentRow) agentName = agentRow.name || agentRow.codename || agentId;
2371
+
2372
+ const completed = all.filter((t: any) => t.status === "DONE");
2373
+ const inProgress = all.filter((t: any) => t.status === "IN_PROGRESS" || t.status === "RUNNING");
2374
+ const pending = all.filter((t: any) => t.status === "PENDING" || t.status === "NEW");
2375
+ const failed = all.filter((t: any) => t.status === "FAILED");
2376
+
2377
+ const lines: string[] = [];
2378
+ lines.push(`📊 Project Report — ${scopeLabel}`);
2379
+ lines.push(`Agent: ${agentName}`);
2380
+ lines.push("");
2381
+ lines.push("━━━ Summary ━━━");
2382
+ lines.push(`✅ ${completed.length} | 🔄 ${inProgress.length} | ⏳ ${pending.length} | ❌ ${failed.length}`);
2383
+
2384
+ if (inProgress.length > 0) {
2385
+ lines.push("");
2386
+ lines.push("━━━ Active ━━━");
2387
+ for (const t of inProgress.slice(0, 8)) {
2388
+ const pl = t.priority === 3 ? "CRIT" : t.priority === 2 ? "HIGH" : t.priority === 1 ? "MED" : "LOW";
2389
+ lines.push(`🔄 [${pl}] ${t.title}${t.progress ? ` — ${t.progress}%` : ""}`);
2390
+ }
2391
+ if (inProgress.length > 8) lines.push(`... +${inProgress.length - 8} more`);
2392
+ }
2393
+
2394
+ if (pending.length > 0) {
2395
+ lines.push("");
2396
+ lines.push("━━━ Pending ━━━");
2397
+ for (const t of pending.slice(0, 6)) {
2398
+ const pl = t.priority === 3 ? "CRIT" : t.priority === 2 ? "HIGH" : t.priority === 1 ? "MED" : "LOW";
2399
+ lines.push(`⏳ [${pl}] ${t.title}`);
2400
+ }
2401
+ if (pending.length > 6) lines.push(`... +${pending.length - 6} more`);
2402
+ }
2403
+
2404
+ if (includeCompleted && completed.length > 0) {
2405
+ lines.push("");
2406
+ lines.push("━━━ Done ━━━");
2407
+ const recent = completed.filter((t: any) => t.completed_at)
2408
+ .sort((a: any, b: any) => new Date(b.completed_at).getTime() - new Date(a.completed_at).getTime())
2409
+ .slice(0, 5);
2410
+ for (const t of recent) {
2411
+ const diff = Date.now() - new Date(t.completed_at).getTime();
2412
+ const mins = Math.floor(diff / 60000);
2413
+ const ago = mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
2414
+ lines.push(`✅ ${t.title} (${ago})`);
2415
+ }
2416
+ }
2417
+
2418
+ const now = new Date();
2419
+ lines.push("");
2420
+ lines.push(`Generated: ${now.toISOString().replace("T", " ").slice(0, 16)} UTC`);
2421
+
2422
+ return { report: lines.join("\n"), scopeLabel, taskCount: all.length };
2423
+ }
2424
+
2425
+ async function dispatchReportDirect(
2426
+ supabase: SupabaseClient,
2427
+ userId: string,
2428
+ agentId: string,
2429
+ report: string,
2430
+ channelTypes: string[],
2431
+ ): Promise<{ sent: string[]; failed: string[] }> {
2432
+ // Query agent's active channel bindings
2433
+ let query = supabase.from("channel_bindings").select("channel_type, channel_account_id, channel_config")
2434
+ .eq("user_id", userId)
2435
+ .eq("agent_id", agentId)
2436
+ .eq("notifications_enabled", true)
2437
+ .eq("is_active", true);
2438
+
2439
+ if (channelTypes.length > 0) {
2440
+ query = query.in("channel_type", channelTypes);
2441
+ }
2442
+
2443
+ const { data: bindings } = await query;
2444
+ if (!bindings || bindings.length === 0) {
2445
+ return { sent: [], failed: ["No active channel bindings found for this agent"] };
2446
+ }
2447
+
2448
+ const LIMITS: Record<string, number> = { telegram: 4096, discord: 2000, slack: 40000, whatsapp: 65536 };
2449
+ const sent: string[] = [];
2450
+ const failed: string[] = [];
2451
+
2452
+ for (const b of bindings) {
2453
+ const limit = LIMITS[b.channel_type] || 4000;
2454
+ let msg = report;
2455
+ if (msg.length > limit) {
2456
+ msg = msg.substring(0, limit - 80) + "\n\n... [Full report on dashboard]";
2457
+ }
2458
+
2459
+ // Use the Ofiere dashboard API to send via gateway
2460
+ // The dashboard URL is derived from the Supabase URL
2461
+ const dashboardUrl = process.env.OFIERE_DASHBOARD_URL || "https://ofiere.com";
2462
+ const dispatchSecret = process.env.DISPATCH_SECRET || "";
2463
+
2464
+ try {
2465
+ const res = await fetch(`${dashboardUrl}/api/channels/gateway-send`, {
2466
+ method: "POST",
2467
+ headers: { "Content-Type": "application/json", "x-dispatch-secret": dispatchSecret },
2468
+ body: JSON.stringify({
2469
+ userId,
2470
+ channelType: b.channel_type,
2471
+ accountId: b.channel_account_id || null,
2472
+ message: msg,
2473
+ }),
2474
+ });
2475
+ if (res.ok) sent.push(b.channel_type);
2476
+ else failed.push(`${b.channel_type}: HTTP ${res.status}`);
2477
+ } catch (e: any) {
2478
+ failed.push(`${b.channel_type}: ${e.message || "fetch error"}`);
2479
+ }
2480
+ }
2481
+
2482
+ return { sent, failed };
2483
+ }
2484
+
2204
2485
  // ═══════════════════════════════════════════════════════════════════════════════
2205
2486
  // META-TOOL 8: OFIERE_MEMORY_OPS — Conversations & Knowledge Fragments
2206
2487
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -3234,19 +3515,62 @@ function registerFileOps(
3234
3515
 
3235
3516
  // ── File Ops helpers ─────────────────────────────────────────────────────────
3236
3517
 
3518
+ /** MIME types allowed by the space-files storage bucket. */
3519
+ const ALLOWED_STORAGE_MIMES = new Set([
3520
+ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml",
3521
+ "video/mp4", "video/webm", "video/quicktime",
3522
+ "audio/mpeg", "audio/wav", "audio/ogg",
3523
+ "application/pdf", "text/plain", "text/markdown", "text/csv",
3524
+ "text/html", "text/css", "application/json",
3525
+ "application/javascript", "text/javascript", "application/typescript",
3526
+ "application/zip", "application/x-tar", "application/gzip",
3527
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
3528
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
3529
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
3530
+ "application/octet-stream",
3531
+ ]);
3532
+
3533
+ /**
3534
+ * Simple FNV-1a 32-bit hash → 8-char hex string.
3535
+ * Used to generate stable, short slugs from non-ASCII filenames.
3536
+ */
3537
+ function fnv1aHash(str: string): string {
3538
+ let hash = 0x811c9dc5;
3539
+ for (let i = 0; i < str.length; i++) {
3540
+ hash ^= str.charCodeAt(i);
3541
+ hash = (hash * 0x01000193) >>> 0;
3542
+ }
3543
+ return hash.toString(16).padStart(8, "0");
3544
+ }
3545
+
3237
3546
  /**
3238
3547
  * Sanitize a filename for safe use as a Supabase storage object key.
3239
- * Strips characters that break storage APIs (parens, em-dashes, exclamation marks, etc.)
3240
- * while preserving extension and readability. The original filename is kept in DB metadata.
3548
+ * Strips characters that break storage APIs while preserving extension and readability.
3549
+ * For Unicode-heavy filenames, generates a hash-based slug to maintain uniqueness.
3550
+ * The original filename is always kept in DB metadata.
3241
3551
  */
3242
3552
  function sanitizeStorageFileName(fileName: string): string {
3243
- // Replace spaces with hyphens, strip anything not alphanumeric, hyphen, underscore, or dot
3244
- return fileName
3553
+ // Split extension from stem
3554
+ const dotIdx = fileName.lastIndexOf(".");
3555
+ const stem = dotIdx > 0 ? fileName.slice(0, dotIdx) : fileName;
3556
+ const ext = dotIdx > 0 ? fileName.slice(dotIdx) : ""; // includes dot
3557
+
3558
+ // Sanitize stem: spaces → hyphens, strip non-ASCII/non-safe chars
3559
+ let safeStem = stem
3245
3560
  .replace(/\s+/g, "-")
3246
- .replace(/[^a-zA-Z0-9._-]/g, "")
3247
- .replace(/-{2,}/g, "-") // collapse multiple hyphens
3248
- .replace(/^-+|-+$/g, "") // trim leading/trailing hyphens
3249
- || "file"; // fallback if everything was stripped
3561
+ .replace(/[^a-zA-Z0-9_-]/g, "")
3562
+ .replace(/-{2,}/g, "-")
3563
+ .replace(/^-+|-+$/g, "");
3564
+
3565
+ // If stem collapsed (e.g. all Unicode), generate hash-based slug for traceability
3566
+ if (safeStem.length < 3) {
3567
+ safeStem = `f-${fnv1aHash(stem)}`;
3568
+ }
3569
+
3570
+ // Sanitize extension too (should be safe but belt-and-suspenders)
3571
+ const safeExt = ext.replace(/[^a-zA-Z0-9.]/g, "");
3572
+
3573
+ return `${safeStem}${safeExt}` || "file";
3250
3574
  }
3251
3575
 
3252
3576
  // ── File Ops handlers ────────────────────────────────────────────────────────
@@ -3408,6 +3732,7 @@ async function handleUploadFile(
3408
3732
 
3409
3733
  // Auto-detect MIME from extension if not explicitly provided
3410
3734
  let mimeType = params.file_type as string;
3735
+ let mimeWarning: string | undefined;
3411
3736
  if (!mimeType) {
3412
3737
  const ext = fileName.split(".").pop()?.toLowerCase() || "";
3413
3738
  const binaryMimeMap: Record<string, string> = {
@@ -3428,6 +3753,10 @@ async function handleUploadFile(
3428
3753
  yml: "text/yaml", xml: "text/xml",
3429
3754
  };
3430
3755
  mimeType = binaryMimeMap[ext] || "application/octet-stream";
3756
+ } else if (!ALLOWED_STORAGE_MIMES.has(mimeType)) {
3757
+ // Explicit MIME not in bucket allowlist → fall back to octet-stream to prevent upload rejection
3758
+ mimeWarning = `Requested MIME "${mimeType}" is not in storage allowlist — stored as application/octet-stream. The original type is preserved in file metadata.`;
3759
+ mimeType = "application/octet-stream";
3431
3760
  }
3432
3761
 
3433
3762
  const safeFileName = sanitizeStorageFileName(fileName);
@@ -3439,6 +3768,10 @@ async function handleUploadFile(
3439
3768
 
3440
3769
  if (uploadErr) return err(`Storage upload failed: ${uploadErr.message}`);
3441
3770
 
3771
+ // Store the originally-requested MIME type in DB (for downstream consumers),
3772
+ // but use the storage-safe MIME for the actual upload
3773
+ const dbMimeType = (params.file_type as string) || mimeType;
3774
+
3442
3775
  const { data, error } = await supabase
3443
3776
  .from("pm_space_files")
3444
3777
  .insert({
@@ -3446,7 +3779,7 @@ async function handleUploadFile(
3446
3779
  space_id: spaceId,
3447
3780
  folder_id: (params.folder_id as string) || null,
3448
3781
  file_name: fileName,
3449
- file_type: mimeType,
3782
+ file_type: dbMimeType,
3450
3783
  file_size: bytes.length,
3451
3784
  storage_path: storagePath,
3452
3785
  is_shared: false,
@@ -3456,7 +3789,12 @@ async function handleUploadFile(
3456
3789
  .single();
3457
3790
 
3458
3791
  if (error) return err(error.message);
3459
- return ok({ message: `File "${fileName}" uploaded (${bytes.length} bytes)`, file: data });
3792
+ const result: Record<string, unknown> = {
3793
+ message: `File "${fileName}" uploaded (${bytes.length} bytes)`,
3794
+ file: data,
3795
+ };
3796
+ if (mimeWarning) result.warning = mimeWarning;
3797
+ return ok(result);
3460
3798
  } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
3461
3799
  }
3462
3800