iriai-build 0.1.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/bin/iriai-build.js +78 -0
- package/bridge-v3.js +98 -0
- package/cli/bootstrap.js +83 -0
- package/cli/commands/implementation.js +64 -0
- package/cli/commands/index.js +46 -0
- package/cli/commands/launch.js +153 -0
- package/cli/commands/plan.js +117 -0
- package/cli/commands/setup.js +80 -0
- package/cli/commands/slack.js +97 -0
- package/cli/commands/transfer.js +111 -0
- package/cli/config.js +92 -0
- package/cli/display.js +121 -0
- package/cli/terminal-input.js +666 -0
- package/cli/wait.js +82 -0
- package/index.js +1488 -0
- package/lib/agent-process.js +170 -0
- package/lib/bridge-state.js +126 -0
- package/lib/constants.js +137 -0
- package/lib/health-monitor.js +113 -0
- package/lib/prompt-builder.js +565 -0
- package/lib/signal-watcher.js +215 -0
- package/lib/slack-helpers.js +224 -0
- package/lib/state-machines/feature-lead.js +408 -0
- package/lib/state-machines/operator-agent.js +173 -0
- package/lib/state-machines/planning-role.js +161 -0
- package/lib/state-machines/role-agent.js +186 -0
- package/lib/state-machines/team-orchestrator.js +160 -0
- package/package.json +31 -0
- package/v3/.handover-html-evidence.md +35 -0
- package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
- package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
- package/v3/adapters/desktop-adapter.js +78 -0
- package/v3/adapters/interface.js +146 -0
- package/v3/adapters/slack-adapter.js +608 -0
- package/v3/adapters/slack-helpers.js +179 -0
- package/v3/adapters/terminal-adapter.js +249 -0
- package/v3/agent-supervisor.js +320 -0
- package/v3/artifact-portal.js +1184 -0
- package/v3/bridge.db +0 -0
- package/v3/constants.js +170 -0
- package/v3/db.js +76 -0
- package/v3/file-io.js +216 -0
- package/v3/helpers.js +174 -0
- package/v3/operator.js +364 -0
- package/v3/orchestrator.js +2886 -0
- package/v3/plan-compiler.js +440 -0
- package/v3/prompt-builder.js +849 -0
- package/v3/queries.js +461 -0
- package/v3/recovery.js +508 -0
- package/v3/review-sessions.js +360 -0
- package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
- package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
- package/v3/roles/architect/CLAUDE.md +809 -0
- package/v3/roles/backend-implementer/CLAUDE.md +97 -0
- package/v3/roles/code-reviewer/CLAUDE.md +89 -0
- package/v3/roles/database-implementer/CLAUDE.md +97 -0
- package/v3/roles/deployer/CLAUDE.md +42 -0
- package/v3/roles/designer/CLAUDE.md +386 -0
- package/v3/roles/documentation/CLAUDE.md +40 -0
- package/v3/roles/feature-lead/CLAUDE.md +233 -0
- package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
- package/v3/roles/implementer/CLAUDE.md +97 -0
- package/v3/roles/integration-tester/CLAUDE.md +174 -0
- package/v3/roles/observability-engineer/CLAUDE.md +40 -0
- package/v3/roles/operator/CLAUDE.md +322 -0
- package/v3/roles/orchestrator/CLAUDE.md +288 -0
- package/v3/roles/package-implementer/CLAUDE.md +47 -0
- package/v3/roles/performance-analyst/CLAUDE.md +49 -0
- package/v3/roles/plan-compiler/CLAUDE.md +163 -0
- package/v3/roles/planning-lead/CLAUDE.md +41 -0
- package/v3/roles/pm/CLAUDE.md +806 -0
- package/v3/roles/regression-tester/CLAUDE.md +135 -0
- package/v3/roles/release-manager/CLAUDE.md +43 -0
- package/v3/roles/security-auditor/CLAUDE.md +90 -0
- package/v3/roles/smoke-tester/CLAUDE.md +97 -0
- package/v3/roles/test-author/CLAUDE.md +42 -0
- package/v3/roles/verifier/CLAUDE.md +90 -0
- package/v3/schema.sql +134 -0
- package/v3/slack-adapter.js +510 -0
- package/v3/slack-helpers.js +346 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// signal-watcher.js — Centralized chokidar-based signal file watcher.
|
|
2
|
+
// Replaces the 5+ fragmented watchers in index.js.
|
|
3
|
+
// Single chokidar instance per signal domain, emits typed events.
|
|
4
|
+
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import chokidar from "chokidar";
|
|
9
|
+
import { ROLE_DIRS, SIGNAL } from "./constants.js";
|
|
10
|
+
import { ensureDir } from "./slack-helpers.js";
|
|
11
|
+
|
|
12
|
+
const WATCHER_OPTS = {
|
|
13
|
+
ignoreInitial: true,
|
|
14
|
+
awaitWriteFinish: { stabilityThreshold: 500 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default class SignalWatcher extends EventEmitter {
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
this._watchers = [];
|
|
21
|
+
this._featureWatchers = new Map(); // slug -> watcher[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Planning Signals ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
watchPlanningSignals() {
|
|
27
|
+
const dirs = Object.values(ROLE_DIRS);
|
|
28
|
+
for (const d of dirs) ensureDir(d);
|
|
29
|
+
|
|
30
|
+
// Watch .agent-response
|
|
31
|
+
this._watch(
|
|
32
|
+
dirs.map((d) => path.join(d, SIGNAL.AGENT_RESPONSE)),
|
|
33
|
+
(fp) => {
|
|
34
|
+
const role = this._roleFromDir(path.dirname(fp));
|
|
35
|
+
if (role) this.emit("planning:response", { role, filePath: fp });
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// NOTE: .done is intentionally NOT watched by chokidar here.
|
|
40
|
+
// PlanningRole state machine emits "done" on exit, and dispatchPlanningRole
|
|
41
|
+
// re-emits it as signalWatcher.emit("planning:done"). Watching it here too
|
|
42
|
+
// would cause double-posting. processExistingSignals() handles stale .done
|
|
43
|
+
// files on startup.
|
|
44
|
+
|
|
45
|
+
// Watch .question
|
|
46
|
+
this._watch(
|
|
47
|
+
dirs.map((d) => path.join(d, SIGNAL.QUESTION)),
|
|
48
|
+
(fp) => {
|
|
49
|
+
const role = this._roleFromDir(path.dirname(fp));
|
|
50
|
+
if (role) this.emit("planning:question", { role, filePath: fp });
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
console.log("[signal-watcher] Watching planning signals");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Implementation Feature Signals ──────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
watchFeatureSignals(slug, tree) {
|
|
60
|
+
const featureWatchers = [];
|
|
61
|
+
const watch = (patterns, handler) => {
|
|
62
|
+
const w = this._createWatcher(patterns, handler);
|
|
63
|
+
featureWatchers.push(w);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Feature Lead .agent-response
|
|
67
|
+
if (tree.featureLead) {
|
|
68
|
+
watch(
|
|
69
|
+
[path.join(tree.featureLead, SIGNAL.AGENT_RESPONSE)],
|
|
70
|
+
(fp) => this.emit("impl:response", { slug, agent: "feature-lead", filePath: fp })
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// .feature-complete
|
|
74
|
+
watch(
|
|
75
|
+
[path.join(tree.featureLead, SIGNAL.FEATURE_COMPLETE)],
|
|
76
|
+
(fp) => this.emit("impl:featureComplete", { slug, filePath: fp })
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// .phase-done
|
|
80
|
+
watch(
|
|
81
|
+
[path.join(tree.featureLead, SIGNAL.PHASE_DONE)],
|
|
82
|
+
(fp) => this.emit("impl:phaseDone", { slug, filePath: fp })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// .context-refresh
|
|
86
|
+
watch(
|
|
87
|
+
[path.join(tree.featureLead, SIGNAL.CONTEXT_REFRESH)],
|
|
88
|
+
(fp) => this.emit("impl:contextRefresh", { slug, filePath: fp })
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Operator .agent-response
|
|
93
|
+
if (tree.operator) {
|
|
94
|
+
watch(
|
|
95
|
+
[path.join(tree.operator, SIGNAL.AGENT_RESPONSE)],
|
|
96
|
+
(fp) => this.emit("impl:operatorResponse", { slug, filePath: fp })
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Operator .user-message (for triggering operator agent)
|
|
100
|
+
watch(
|
|
101
|
+
[path.join(tree.operator, SIGNAL.USER_MESSAGE)],
|
|
102
|
+
(fp) => this.emit("impl:userMessage", { slug, agent: "operator", filePath: fp })
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Collect all agent dirs for .needs-restart watching
|
|
107
|
+
const allDirs = [];
|
|
108
|
+
if (tree.featureLead) allDirs.push(tree.featureLead);
|
|
109
|
+
if (tree.operator) allDirs.push(tree.operator);
|
|
110
|
+
|
|
111
|
+
for (const [teamNum, team] of Object.entries(tree.teams)) {
|
|
112
|
+
if (team.orchestrator) {
|
|
113
|
+
allDirs.push(team.orchestrator);
|
|
114
|
+
|
|
115
|
+
// Watch .gate-ready per team
|
|
116
|
+
watch(
|
|
117
|
+
[path.join(team.orchestrator, SIGNAL.GATE_READY)],
|
|
118
|
+
(fp) => this.emit("impl:gateReady", { slug, teamNum, filePath: fp })
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Watch .crashed per team orchestrator
|
|
122
|
+
watch(
|
|
123
|
+
[path.join(team.orchestrator, SIGNAL.CRASHED)],
|
|
124
|
+
(fp) => this.emit("impl:crashed", { slug, agent: `team-${teamNum}-orch`, teamNum, filePath: fp })
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Watch .question per team orchestrator
|
|
128
|
+
watch(
|
|
129
|
+
[path.join(team.orchestrator, SIGNAL.QUESTION)],
|
|
130
|
+
(fp) => this.emit("impl:question", { slug, teamNum, filePath: fp })
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Watch .task for team orchestrator (dispatched by Feature Lead)
|
|
134
|
+
watch(
|
|
135
|
+
[path.join(team.orchestrator, SIGNAL.TASK)],
|
|
136
|
+
(fp) => this.emit("impl:orchTask", { slug, teamNum, filePath: fp })
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const [role, roleDir] of Object.entries(team.roles)) {
|
|
141
|
+
allDirs.push(roleDir);
|
|
142
|
+
|
|
143
|
+
// Watch .task for role agents
|
|
144
|
+
watch(
|
|
145
|
+
[path.join(roleDir, SIGNAL.TASK)],
|
|
146
|
+
(fp) => this.emit("impl:task", { slug, teamNum, role, filePath: fp })
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Watch .done for role agents
|
|
150
|
+
watch(
|
|
151
|
+
[path.join(roleDir, SIGNAL.DONE)],
|
|
152
|
+
(fp) => this.emit("impl:done", { slug, teamNum, role, filePath: fp })
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Feature review roles
|
|
158
|
+
for (const [role, reviewDir] of Object.entries(tree.featureReview)) {
|
|
159
|
+
allDirs.push(reviewDir);
|
|
160
|
+
|
|
161
|
+
watch(
|
|
162
|
+
[path.join(reviewDir, SIGNAL.DONE)],
|
|
163
|
+
(fp) => this.emit("impl:done", { slug, agent: `review-${role}`, role, filePath: fp })
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Watch .needs-restart across ALL agent dirs
|
|
168
|
+
watch(
|
|
169
|
+
allDirs.map((d) => path.join(d, SIGNAL.NEEDS_RESTART)),
|
|
170
|
+
(fp) => this.emit("impl:needsRestart", { slug, dir: path.dirname(fp), filePath: fp })
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
this._featureWatchers.set(slug, featureWatchers);
|
|
174
|
+
console.log(`[signal-watcher] Watching impl signals for ${slug}: ${allDirs.length} dirs`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
unwatchFeature(slug) {
|
|
178
|
+
const watchers = this._featureWatchers.get(slug);
|
|
179
|
+
if (watchers) {
|
|
180
|
+
for (const w of watchers) w.close();
|
|
181
|
+
this._featureWatchers.delete(slug);
|
|
182
|
+
console.log(`[signal-watcher] Unwatched feature ${slug}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Cleanup ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async closeAll() {
|
|
189
|
+
for (const w of this._watchers) await w.close();
|
|
190
|
+
for (const [, watchers] of this._featureWatchers) {
|
|
191
|
+
for (const w of watchers) await w.close();
|
|
192
|
+
}
|
|
193
|
+
this._watchers = [];
|
|
194
|
+
this._featureWatchers.clear();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Internals ───────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
_watch(patterns, handler) {
|
|
200
|
+
const w = this._createWatcher(patterns, handler);
|
|
201
|
+
this._watchers.push(w);
|
|
202
|
+
return w;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_createWatcher(patterns, handler) {
|
|
206
|
+
const w = chokidar.watch(patterns, WATCHER_OPTS);
|
|
207
|
+
w.on("add", handler);
|
|
208
|
+
w.on("change", handler);
|
|
209
|
+
return w;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_roleFromDir(dir) {
|
|
213
|
+
return Object.entries(ROLE_DIRS).find(([, d]) => d === dir)?.[0] || null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// slack-helpers.js — Pure helper functions for Slack API and file operations.
|
|
2
|
+
// Extracted from index.js lines 82-210.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { IRIAI_TEAM_DIR, KNOWN_REPOS } from "./constants.js";
|
|
7
|
+
|
|
8
|
+
export function slugify(text) {
|
|
9
|
+
return text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-|-$/g, "")
|
|
13
|
+
.slice(0, 40);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ensureDir(dir) {
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function postToThread(web, channel, thread_ts, text) {
|
|
21
|
+
await web.chat.postMessage({ channel, thread_ts, text: markdownToMrkdwn(text), mrkdwn: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function addReaction(web, channel, timestamp, reaction) {
|
|
25
|
+
try {
|
|
26
|
+
await web.reactions.add({ channel, name: reaction, timestamp });
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore — reaction may already exist or message may be gone
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function removeReaction(web, channel, timestamp, reaction) {
|
|
33
|
+
try {
|
|
34
|
+
await web.reactions.remove({ channel, name: reaction, timestamp });
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore — reaction may not exist
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findArtifact(filename) {
|
|
41
|
+
const planDir = path.join(IRIAI_TEAM_DIR, "implementation-plans", "current");
|
|
42
|
+
try {
|
|
43
|
+
const files = fs.readdirSync(planDir);
|
|
44
|
+
const match = files.find((f) => f.includes(filename) && f.endsWith(".md"));
|
|
45
|
+
if (match) return path.join(planDir, match);
|
|
46
|
+
} catch {
|
|
47
|
+
// Directory or file doesn't exist yet
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function uploadArtifact(web, channel, thread_ts, filePath, title) {
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
55
|
+
await web.filesUploadV2({
|
|
56
|
+
channel_id: channel,
|
|
57
|
+
thread_ts,
|
|
58
|
+
content,
|
|
59
|
+
filename: path.basename(filePath),
|
|
60
|
+
title,
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`Error uploading artifact ${title}:`, err.message);
|
|
64
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
65
|
+
const truncated =
|
|
66
|
+
content.length > 3000
|
|
67
|
+
? content.slice(0, 3000) + "\n\n_(truncated — full document in repo)_"
|
|
68
|
+
: content;
|
|
69
|
+
await postToThread(web, channel, thread_ts, `*${title}:*\n\n${truncated}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function detectReposFromPlan() {
|
|
74
|
+
const planPath = findArtifact("implementation-plan");
|
|
75
|
+
if (!planPath) return [];
|
|
76
|
+
const content = fs.readFileSync(planPath, "utf-8");
|
|
77
|
+
return KNOWN_REPOS.filter((repo) => content.includes(repo));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseRole(text) {
|
|
81
|
+
const lower = text.toLowerCase();
|
|
82
|
+
if (lower.includes("@pm")) return "pm";
|
|
83
|
+
if (lower.includes("@designer")) return "designer";
|
|
84
|
+
if (lower.includes("@architect")) return "architect";
|
|
85
|
+
if (lower.includes("@compiler") || lower.includes("@plan-compiler"))
|
|
86
|
+
return "plan-compiler";
|
|
87
|
+
if (lower.includes("@lead")) return "lead";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read a signal file, return content, and optionally delete it.
|
|
93
|
+
* Returns empty string if file doesn't exist.
|
|
94
|
+
*/
|
|
95
|
+
export function readSignal(filePath, { deleteAfter = false } = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(filePath, "utf-8").trim();
|
|
98
|
+
if (deleteAfter) fs.unlinkSync(filePath);
|
|
99
|
+
return content;
|
|
100
|
+
} catch {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write content to a signal file (ensures parent dir exists).
|
|
107
|
+
*/
|
|
108
|
+
export function writeSignal(filePath, content) {
|
|
109
|
+
ensureDir(path.dirname(filePath));
|
|
110
|
+
fs.writeFileSync(filePath, content);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convert standard Markdown to Slack mrkdwn format.
|
|
115
|
+
* Handles: **bold**→*bold*, ## headers→*Header*, [text](url)→<url|text>,
|
|
116
|
+
* *italic*→_italic_, →[img:path]. Preserves code blocks.
|
|
117
|
+
*/
|
|
118
|
+
export function markdownToMrkdwn(text) {
|
|
119
|
+
if (!text) return text;
|
|
120
|
+
|
|
121
|
+
// Extract code blocks to protect them from conversion
|
|
122
|
+
const codeBlocks = [];
|
|
123
|
+
let result = text.replace(/```[\s\S]*?```/g, (match) => {
|
|
124
|
+
codeBlocks.push(match);
|
|
125
|
+
return `\x00CODE${codeBlocks.length - 1}\x00`;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Extract inline code
|
|
129
|
+
const inlineCode = [];
|
|
130
|
+
result = result.replace(/`[^`]+`/g, (match) => {
|
|
131
|
+
inlineCode.push(match);
|
|
132
|
+
return `\x00INLINE${inlineCode.length - 1}\x00`;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Headers → bold (Slack has no headers)
|
|
136
|
+
result = result.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
|
|
137
|
+
|
|
138
|
+
// Bold: **text** → *text*
|
|
139
|
+
result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
|
|
140
|
+
|
|
141
|
+
// Italic: _text_ stays as-is (works in both)
|
|
142
|
+
// Standalone *text* (not **) that isn't already converted → _text_
|
|
143
|
+
// This is tricky — after bold conversion, single * pairs are already bold in Slack.
|
|
144
|
+
// Skip this to avoid double-converting.
|
|
145
|
+
|
|
146
|
+
// Links: [text](url) → <url|text>
|
|
147
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
|
|
148
|
+
|
|
149
|
+
// Image refs:  → [img:path]
|
|
150
|
+
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "[img:$2]");
|
|
151
|
+
|
|
152
|
+
// Restore inline code
|
|
153
|
+
result = result.replace(/\x00INLINE(\d+)\x00/g, (_, i) => inlineCode[i]);
|
|
154
|
+
|
|
155
|
+
// Restore code blocks
|
|
156
|
+
result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeBlocks[i]);
|
|
157
|
+
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extract media markers from message text: [gif:<path>], [img:<path>], [video:<path>].
|
|
163
|
+
* Returns the cleaned text (markers removed) and an array of file paths.
|
|
164
|
+
*/
|
|
165
|
+
export function parseGifMarkers(text) {
|
|
166
|
+
const gifPaths = [];
|
|
167
|
+
const cleaned = text.replace(/\[(gif|img|image|video|screenshot):([^\]]+)\]/gi, (match, type, filePath) => {
|
|
168
|
+
gifPaths.push(filePath.trim());
|
|
169
|
+
return "";
|
|
170
|
+
});
|
|
171
|
+
return { text: cleaned.trim(), gifPaths };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check whether a file path is within the allowed directories for media uploads.
|
|
176
|
+
* Prevents path-traversal attacks that could exfiltrate arbitrary files.
|
|
177
|
+
*/
|
|
178
|
+
function isMediaPathAllowed(filePath) {
|
|
179
|
+
const resolved = path.resolve(filePath);
|
|
180
|
+
const ALLOWED_DIRS = [
|
|
181
|
+
path.resolve('/Users/danielzhang/src/iriai'),
|
|
182
|
+
'/tmp',
|
|
183
|
+
'/private/tmp',
|
|
184
|
+
];
|
|
185
|
+
return ALLOWED_DIRS.some(dir => resolved.startsWith(dir + path.sep) || resolved === dir);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Upload a binary file (GIF, PNG, JPG, WebM, MP4) as a Slack attachment.
|
|
190
|
+
* Reads the file as a Buffer and uses filesUploadV2.
|
|
191
|
+
* Silently skips if file doesn't exist or exceeds 20MB.
|
|
192
|
+
*/
|
|
193
|
+
export async function uploadGifAttachment(web, channel, thread_ts, filePath, title) {
|
|
194
|
+
try {
|
|
195
|
+
if (!isMediaPathAllowed(filePath)) {
|
|
196
|
+
console.warn(`[uploadGifAttachment] Path not in allowed directories: ${filePath}`);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!fs.existsSync(filePath)) {
|
|
201
|
+
console.warn(`[bridge] Media file not found: ${filePath}`);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stats = fs.statSync(filePath);
|
|
206
|
+
if (stats.size > 20 * 1024 * 1024) {
|
|
207
|
+
console.warn(`[bridge] Media too large (${(stats.size / 1024 / 1024).toFixed(1)}MB): ${filePath}`);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
212
|
+
await web.filesUploadV2({
|
|
213
|
+
channel_id: channel,
|
|
214
|
+
thread_ts,
|
|
215
|
+
file: fileBuffer,
|
|
216
|
+
filename: path.basename(filePath),
|
|
217
|
+
title: title || path.basename(filePath),
|
|
218
|
+
});
|
|
219
|
+
return true;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(`[bridge] Media upload failed for ${filePath}:`, err.message);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|