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 +2 -2
- package/package.json +1 -1
- package/src/prompt.ts +14 -2
- package/src/tools.ts +353 -15
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://
|
|
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://
|
|
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.
|
|
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
|
|
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
|
|
3240
|
-
*
|
|
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
|
-
//
|
|
3244
|
-
|
|
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-
|
|
3247
|
-
.replace(/-{2,}/g, "-")
|
|
3248
|
-
.replace(/^-+|-+$/g, "")
|
|
3249
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|