@ghl-ai/aw 0.1.37-beta.8 → 0.1.37

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,483 @@
1
+ // render-rules.mjs — Render .aw_rules into IDE-specific output files.
2
+
3
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import * as fmt from './fmt.mjs';
7
+ import { RULES_SOURCE_DIR } from './constants.mjs';
8
+
9
+ const GENERATED_HEADER = '<!-- Generated by aw — do not edit manually -->\n\n';
10
+ const STACK_OVERLAY_FLAG = 'AW_ENABLE_STACK_OVERLAY_RULES';
11
+
12
+ /** Rule scope → Cursor .mdc glob patterns */
13
+ const SCOPE_GLOBS = {
14
+ 'api-design': '**/*.controller.ts,**/dto/**/*.ts,**/*.client.ts',
15
+ backend: '**/*.service.ts,**/*.controller.ts,**/*.module.ts,**/*.worker.ts',
16
+ 'backend/go-connect': '**/*.connect.go,**/*.pb.go,**/buf.gen.yaml,**/buf.yaml',
17
+ 'backend/nestjs': '**/*.controller.ts,**/*.module.ts,**/dto/**/*.ts,**/*.guard.ts,**/*.pipe.ts,**/*.interceptor.ts',
18
+ frontend: '**/*.vue,**/*.composable.ts,**/components/**/*.ts',
19
+ 'frontend/nuxt': '**/app.vue,**/pages/**/*.vue,**/layouts/**/*.vue,**/plugins/**/*.ts,**/middleware/**/*.ts,**/server/api/**/*.ts,**/nuxt.config.*',
20
+ 'frontend/vue': '**/*.vue,**/*.composable.ts,**/composables/**/*.ts',
21
+ data: '**/*.schema.ts,**/*.migration.*,**/*.repository.ts',
22
+ infra: '**/helm/**,**/terraform/**,**/Dockerfile*,**/Jenkinsfile*',
23
+ mobile: '**/*.dart,**/lib/**,**/widget_test/**',
24
+ sdet: '**/e2e/**,**/playwright/**,**/*.spec.ts',
25
+ security: '**/*.ts',
26
+ universal: '',
27
+ };
28
+
29
+ /**
30
+ * Read a file and return its content, or null if it doesn't exist.
31
+ */
32
+ function readOrNull(path) {
33
+ try {
34
+ return readFileSync(path, 'utf8');
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read manifest.json from rules dir.
42
+ */
43
+ function readManifest(rulesDir) {
44
+ for (const filename of ['rule-manifest.json', 'manifest.json']) {
45
+ const content = readOrNull(join(rulesDir, filename));
46
+ if (!content) continue;
47
+ try {
48
+ return JSON.parse(content);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function stripQuotes(value) {
57
+ return value.replace(/^['"]|['"]$/g, '');
58
+ }
59
+
60
+ function parseInlineList(value) {
61
+ const inner = value.trim().replace(/^\[/, '').replace(/\]$/, '');
62
+ if (!inner.trim()) return [];
63
+ return inner.split(',').map((item) => stripQuotes(item.trim())).filter(Boolean);
64
+ }
65
+
66
+ function parseRuleFrontmatter(markdown) {
67
+ const match = markdown.match(/^---\n([\s\S]*?)\n---/);
68
+ if (!match) return {};
69
+
70
+ const result = {};
71
+ let currentKey = null;
72
+
73
+ for (const rawLine of match[1].split('\n')) {
74
+ const line = rawLine.trimEnd();
75
+ if (!line.trim()) continue;
76
+
77
+ const listItem = line.match(/^\s*-\s+(.+)$/);
78
+ if (listItem && currentKey) {
79
+ if (!Array.isArray(result[currentKey])) result[currentKey] = [];
80
+ result[currentKey].push(stripQuotes(listItem[1].trim()));
81
+ continue;
82
+ }
83
+
84
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
85
+ if (!kv) continue;
86
+
87
+ const [, key, rawValue] = kv;
88
+ currentKey = key;
89
+ const value = rawValue.trim();
90
+
91
+ if (value === '') {
92
+ result[key] = [];
93
+ continue;
94
+ }
95
+
96
+ if (value.startsWith('[') && value.endsWith(']')) {
97
+ result[key] = parseInlineList(value);
98
+ continue;
99
+ }
100
+
101
+ if (value === 'true') {
102
+ result[key] = true;
103
+ continue;
104
+ }
105
+
106
+ if (value === 'false') {
107
+ result[key] = false;
108
+ continue;
109
+ }
110
+
111
+ result[key] = stripQuotes(value);
112
+ }
113
+
114
+ return result;
115
+ }
116
+
117
+ function globToRegExp(pattern) {
118
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
119
+ const withGlobstar = escaped.replace(/\*\*/g, '::GLOBSTAR::');
120
+ const withStar = withGlobstar.replace(/\*/g, '[^/]*');
121
+ const finalPattern = withStar.replace(/::GLOBSTAR::/g, '.*');
122
+ return new RegExp(`^${finalPattern}$`);
123
+ }
124
+
125
+ function pathMatchesAnyPattern(filePath, patterns = []) {
126
+ if (!patterns.length) return true;
127
+ return patterns.some((pattern) => globToRegExp(pattern).test(filePath));
128
+ }
129
+
130
+ function textMatchesAny(content, patterns = []) {
131
+ if (!patterns.length) return true;
132
+ return patterns.some((pattern) => content.includes(pattern));
133
+ }
134
+
135
+ function importsMatchAny(content, imports = []) {
136
+ if (!imports.length) return true;
137
+ return imports.some((entry) => {
138
+ const escaped = entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
139
+ const importRe = new RegExp(`(?:from\\s+['"]${escaped}['"]|require\\(\\s*['"]${escaped}['"]\\s*\\)|import\\s+['"]${escaped}['"])`);
140
+ return importRe.test(content);
141
+ });
142
+ }
143
+
144
+ export function ruleAppliesToFile(ruleMarkdown, { filePath = '', fileContent = '' } = {}) {
145
+ const frontmatter = parseRuleFrontmatter(ruleMarkdown);
146
+
147
+ if (frontmatter.fileLevel === false) return false;
148
+ if (!pathMatchesAnyPattern(filePath, frontmatter.paths || [])) return false;
149
+ if (!importsMatchAny(fileContent, frontmatter.requiresImports || [])) return false;
150
+ if (!textMatchesAny(fileContent, frontmatter.requiresText || [])) return false;
151
+
152
+ return true;
153
+ }
154
+
155
+ function scopeToFilename(scope) {
156
+ return scope.replaceAll('/', '-');
157
+ }
158
+
159
+ function scopeToLabel(scope) {
160
+ return scope.replaceAll('/', ' ');
161
+ }
162
+
163
+ function pruneStaleGeneratedRules(outputDir, expectedFilenames) {
164
+ if (!existsSync(outputDir)) return;
165
+
166
+ for (const entry of readdirSync(outputDir, { withFileTypes: true })) {
167
+ if (!entry.isFile()) continue;
168
+ if (expectedFilenames.has(entry.name)) continue;
169
+
170
+ const fullPath = join(outputDir, entry.name);
171
+ const content = readOrNull(fullPath);
172
+ if (!content?.startsWith(GENERATED_HEADER)) continue;
173
+
174
+ try {
175
+ unlinkSync(fullPath);
176
+ } catch {
177
+ // Best effort cleanup; stale files should not break rendering.
178
+ }
179
+ }
180
+ }
181
+
182
+ function stackOverlaysEnabled(options = {}) {
183
+ if (typeof options.enableStackOverlays === 'boolean') {
184
+ return options.enableStackOverlays;
185
+ }
186
+ return process.env[STACK_OVERLAY_FLAG] === '1';
187
+ }
188
+
189
+ /**
190
+ * List all rule scopes under rulesDir/platform/, including nested stack overlays.
191
+ */
192
+ function listRuleScopes(rulesDir, { includeNestedScopes = false } = {}) {
193
+ const platformDir = join(rulesDir, 'platform');
194
+ if (!existsSync(platformDir)) return [];
195
+
196
+ const scopes = [];
197
+
198
+ function visit(relPath = '') {
199
+ const dirPath = relPath ? join(platformDir, relPath) : platformDir;
200
+ const agentsPath = join(dirPath, 'AGENTS.md');
201
+ if (relPath && existsSync(agentsPath)) {
202
+ scopes.push({
203
+ scope: relPath.replaceAll('\\', '/'),
204
+ agentsPath,
205
+ referencesDir: join(dirPath, 'references'),
206
+ });
207
+ }
208
+
209
+ // Default rollout keeps nested stack overlays off until explicitly enabled.
210
+ if (relPath && !includeNestedScopes) return;
211
+
212
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
213
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'references') continue;
214
+ const childRelPath = relPath ? `${relPath}/${entry.name}` : entry.name;
215
+ visit(childRelPath);
216
+ }
217
+ }
218
+
219
+ visit();
220
+
221
+ return scopes.sort((left, right) => {
222
+ const depthDiff = left.scope.split('/').length - right.scope.split('/').length;
223
+ if (depthDiff !== 0) return depthDiff;
224
+ return left.scope.localeCompare(right.scope);
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Render .cursor/rules/<scope>.mdc files from .aw_rules scope AGENTS.md files.
230
+ */
231
+ function renderCursorRules(cwd, rulesDir, options = {}) {
232
+ const cursorRulesDir = join(cwd, '.cursor', 'rules');
233
+ mkdirSync(cursorRulesDir, { recursive: true });
234
+
235
+ const scopes = listRuleScopes(rulesDir, {
236
+ includeNestedScopes: stackOverlaysEnabled(options),
237
+ });
238
+ pruneStaleGeneratedRules(
239
+ cursorRulesDir,
240
+ new Set(scopes.map(({ scope }) => `${scopeToFilename(scope)}.mdc`)),
241
+ );
242
+ let count = 0;
243
+
244
+ for (const { scope, agentsPath, referencesDir } of scopes) {
245
+ const agentsMd = readOrNull(agentsPath);
246
+ if (!agentsMd) continue;
247
+
248
+ const globs = SCOPE_GLOBS[scope] || '';
249
+ const alwaysApply = scope === 'universal' || scope === 'security';
250
+
251
+ // Build .mdc frontmatter
252
+ const frontmatter = [
253
+ '---',
254
+ `description: GHL ${scopeToLabel(scope)} coding rules`,
255
+ ];
256
+ if (alwaysApply) {
257
+ frontmatter.push('alwaysApply: true');
258
+ } else if (globs) {
259
+ frontmatter.push(`globs: ${globs}`);
260
+ }
261
+ frontmatter.push('---');
262
+
263
+ // Only add references listing if the AGENTS.md doesn't already have one
264
+ let refSection = '';
265
+ const hasReferences = agentsMd.includes('## References');
266
+ if (!hasReferences && existsSync(referencesDir)) {
267
+ const refs = readdirSync(referencesDir).filter(f => f.endsWith('.md')).sort();
268
+ if (refs.length > 0) {
269
+ refSection = '\n## References\n\n' +
270
+ refs.map(f => `- [${f.replace('.md', '')}](.aw_registry/.aw_rules/platform/${scope}/references/${f})`).join('\n') +
271
+ '\n';
272
+ }
273
+ }
274
+
275
+ const content = GENERATED_HEADER + frontmatter.join('\n') + '\n\n' + agentsMd.trim() + '\n' + refSection;
276
+ writeFileSync(join(cursorRulesDir, `${scopeToFilename(scope)}.mdc`), content);
277
+ count++;
278
+ }
279
+
280
+ return count;
281
+ }
282
+
283
+ /** Rule scope → Claude Code paths: frontmatter (only supported field for .claude/rules/) */
284
+ const SCOPE_PATHS = {
285
+ 'api-design': ['src/**/*.controller.ts', 'src/**/dto/**/*.ts', 'src/**/*.client.ts'],
286
+ backend: ['src/**/*.service.ts', 'src/**/*.controller.ts', 'src/**/*.module.ts', 'src/**/*.worker.ts'],
287
+ 'backend/go-connect': ['**/*.connect.go', '**/*.pb.go', '**/buf.gen.yaml', '**/buf.yaml'],
288
+ 'backend/nestjs': ['src/**/*.controller.ts', 'src/**/*.module.ts', 'src/**/dto/**/*.ts', 'src/**/*.guard.ts', 'src/**/*.pipe.ts', 'src/**/*.interceptor.ts'],
289
+ frontend: ['src/**/*.vue', 'src/**/*.composable.ts', 'src/**/components/**/*.ts'],
290
+ 'frontend/nuxt': ['app.vue', 'pages/**/*.vue', 'layouts/**/*.vue', 'plugins/**/*.ts', 'middleware/**/*.ts', 'server/api/**/*.ts', 'nuxt.config.*'],
291
+ 'frontend/vue': ['src/**/*.vue', 'src/**/*.composable.ts', 'src/**/composables/**/*.ts'],
292
+ data: ['src/**/*.schema.ts', 'src/**/*.migration.*', 'src/**/*.repository.ts'],
293
+ infra: ['**/helm/**', '**/terraform/**', '**/Dockerfile*', '**/Jenkinsfile*'],
294
+ mobile: ['**/*.dart', '**/lib/**', '**/widget_test/**'],
295
+ sdet: ['**/e2e/**', '**/playwright/**', '**/*.spec.ts'],
296
+ security: null, // no paths = always loaded
297
+ universal: null, // no paths = always loaded
298
+ };
299
+
300
+ const SCOPE_HINTS = {
301
+ 'api-design': '`*.controller.ts`, `dto/*.ts`, service clients',
302
+ backend: '`*.service.ts`, `*.controller.ts`, `*.module.ts`, `*.worker.ts`',
303
+ 'backend/go-connect': 'Connect-RPC Go handlers, generated stubs, `buf.yaml`',
304
+ 'backend/nestjs': 'NestJS controllers, modules, DTOs, guards, pipes',
305
+ data: '`*.schema.ts`, `*.repository.ts`, `migrations/*`',
306
+ frontend: '`*.vue`, `composables/*.ts`, `stores/*.ts`',
307
+ 'frontend/nuxt': 'Nuxt `app.vue`, pages, layouts, plugins, `server/api`',
308
+ 'frontend/vue': 'Vue SFCs and shared Vue composables',
309
+ infra: '`*.tf`, `*.yaml`, `Jenkinsfile`, `Dockerfile`, `helm/*`',
310
+ mobile: '`*.dart`, `lib/**`, widget tests',
311
+ sdet: 'Playwright tests, `*.spec.ts` in test repos',
312
+ security: 'Auth, secrets, input handling, rendering (always)',
313
+ universal: 'All files (always)',
314
+ };
315
+
316
+ /**
317
+ * Render .claude/rules/platform/<scope>.md files from .aw_rules scope AGENTS.md files.
318
+ * Claude Code supports `paths:` frontmatter — rules with paths load only for matching files.
319
+ * Rules without paths are loaded unconditionally.
320
+ */
321
+ function renderClaudeRules(cwd, rulesDir, options = {}) {
322
+ const claudeRulesDir = join(cwd, '.claude', 'rules', 'platform');
323
+ mkdirSync(claudeRulesDir, { recursive: true });
324
+
325
+ const scopes = listRuleScopes(rulesDir, {
326
+ includeNestedScopes: stackOverlaysEnabled(options),
327
+ });
328
+ pruneStaleGeneratedRules(
329
+ claudeRulesDir,
330
+ new Set(scopes.map(({ scope }) => `${scopeToFilename(scope)}.md`)),
331
+ );
332
+ let count = 0;
333
+
334
+ for (const { scope, agentsPath, referencesDir } of scopes) {
335
+ const agentsMd = readOrNull(agentsPath);
336
+ if (!agentsMd) continue;
337
+
338
+ // Build paths frontmatter for scope-specific rules
339
+ const paths = SCOPE_PATHS[scope];
340
+ let frontmatter = '';
341
+ if (paths) {
342
+ frontmatter = '---\npaths:\n' + paths.map(p => ` - "${p}"`).join('\n') + '\n---\n\n';
343
+ }
344
+
345
+ let refSection = '';
346
+ if (!agentsMd.includes('## References') && existsSync(referencesDir)) {
347
+ const refs = readdirSync(referencesDir).filter(f => f.endsWith('.md')).sort();
348
+ if (refs.length > 0) {
349
+ refSection = '\n## References\n\n' +
350
+ refs.map(f => `- [${f.replace('.md', '')}](.aw_registry/.aw_rules/platform/${scope}/references/${f})`).join('\n') +
351
+ '\n';
352
+ }
353
+ }
354
+
355
+ const content = GENERATED_HEADER + frontmatter + agentsMd.trim() + '\n' + refSection;
356
+ writeFileSync(join(claudeRulesDir, `${scopeToFilename(scope)}.md`), content);
357
+ count++;
358
+ }
359
+
360
+ return count;
361
+ }
362
+
363
+ /**
364
+ * Generate a rules section for CLAUDE.md from .aw_rules.
365
+ */
366
+ export function generateClaudeMdRulesSection(rulesDir) {
367
+ const manifest = readManifest(rulesDir);
368
+ if (!manifest) return '';
369
+
370
+ const mustRules = manifest.rules.filter(r => r.severity === 'MUST');
371
+ if (mustRules.length === 0) return '';
372
+
373
+ const lines = [
374
+ '## Platform Rules (MUST)',
375
+ '',
376
+ '> Auto-synced from `.aw_registry/.aw_rules/`. Full details in reference files.',
377
+ '',
378
+ ];
379
+
380
+ for (const rule of mustRules) {
381
+ lines.push(`- [ ] **${rule.id}** — ${rule.description}`);
382
+ }
383
+
384
+ lines.push('');
385
+ lines.push('See `.aw_registry/.aw_rules/rule-manifest.json` for all rules including SHOULD/MAY.');
386
+ lines.push('');
387
+
388
+ return lines.join('\n');
389
+ }
390
+
391
+ /**
392
+ * Generate a rules section for AGENTS.md from the top-level AGENTS.md in .aw_rules.
393
+ */
394
+ export function generateAgentsMdRulesSection(rulesDir, options = {}) {
395
+ const topLevelAgents = readOrNull(join(rulesDir, 'AGENTS.md'));
396
+ if (!topLevelAgents) return '';
397
+
398
+ const lines = [
399
+ '## Platform Rules — Non-Negotiables',
400
+ '',
401
+ '> Auto-synced from `.aw_registry/.aw_rules/`.',
402
+ '',
403
+ topLevelAgents.trim(),
404
+ '',
405
+ ];
406
+
407
+ // Reference table — tells all IDEs (especially Codex) where to read domain rules.
408
+ // Codex can't auto-trigger by glob, but it CAN read these files when working in
409
+ // the matching area. Keep AGENTS.md lean; full content stays in the source files.
410
+ const scopes = listRuleScopes(rulesDir, {
411
+ includeNestedScopes: stackOverlaysEnabled(options),
412
+ });
413
+ const domains = scopes.filter(({ scope }) => !scope.includes('/'));
414
+ const overlays = scopes.filter(({ scope }) => scope.includes('/'));
415
+ if (domains.length > 0) {
416
+ lines.push('### Domain Rules');
417
+ lines.push('');
418
+ lines.push('When working in a specific domain, read the matching rules file:');
419
+ lines.push('');
420
+ lines.push('| Domain | Read when editing | Rules file |');
421
+ lines.push('|--------|-------------------|------------|');
422
+
423
+ for (const { scope } of domains) {
424
+ const hint = SCOPE_HINTS[scope] || 'Related files';
425
+ lines.push(`| ${scope} | ${hint} | \`.aw_registry/.aw_rules/platform/${scope}/AGENTS.md\` |`);
426
+ }
427
+ lines.push('');
428
+ }
429
+
430
+ if (overlays.length > 0) {
431
+ lines.push('### Stack Overlays');
432
+ lines.push('');
433
+ lines.push('When a repo or file clearly matches a stack-specific slice, also read the matching overlay:');
434
+ lines.push('');
435
+ lines.push('| Scope | Read when editing | Rules file |');
436
+ lines.push('|-------|-------------------|------------|');
437
+
438
+ for (const { scope } of overlays) {
439
+ const hint = SCOPE_HINTS[scope] || 'Stack-specific files';
440
+ lines.push(`| ${scope} | ${hint} | \`.aw_registry/.aw_rules/platform/${scope}/AGENTS.md\` |`);
441
+ }
442
+ lines.push('');
443
+ }
444
+
445
+ return lines.join('\n');
446
+ }
447
+
448
+ /**
449
+ * Main render function. Call after aw pull / aw sync.
450
+ * Reads .aw_registry/.aw_rules/ and renders:
451
+ * 1. .cursor/rules/<scope>.mdc — at cwd AND at $HOME (global)
452
+ * 2. Returns sections for CLAUDE.md and AGENTS.md injection
453
+ */
454
+ export function renderRules(cwd, options = {}) {
455
+ const rulesDir = join(cwd, '.aw_registry', RULES_SOURCE_DIR);
456
+ if (!existsSync(rulesDir)) return { cursorCount: 0, claudeSection: '', agentsSection: '' };
457
+
458
+ const cursorCount = renderCursorRules(cwd, rulesDir, options);
459
+ const claudeCount = renderClaudeRules(cwd, rulesDir, options);
460
+ const claudeSection = generateClaudeMdRulesSection(rulesDir);
461
+ const agentsSection = generateAgentsMdRulesSection(rulesDir, options);
462
+
463
+ // Also render to global dirs so rules apply everywhere
464
+ const HOME = options.homeDir || homedir();
465
+ let globalCursorCount = 0;
466
+ let globalClaudeCount = 0;
467
+ if (cwd !== HOME) {
468
+ globalCursorCount = renderCursorRules(HOME, rulesDir, options);
469
+ globalClaudeCount = renderClaudeRules(HOME, rulesDir, options);
470
+ }
471
+
472
+ const total = cursorCount + claudeCount + globalCursorCount + globalClaudeCount;
473
+ if (total > 0) {
474
+ const parts = [];
475
+ if (cursorCount > 0) parts.push(`${cursorCount} .cursor/rules`);
476
+ if (claudeCount > 0) parts.push(`${claudeCount} .claude/rules`);
477
+ if (globalCursorCount > 0 || globalClaudeCount > 0) parts.push(`global`);
478
+ fmt.logSuccess(`Rendered ${parts.join(' + ')}`);
479
+ }
480
+
481
+ // Codex gets top-level non-negotiables plus rule file references via AGENTS.md.
482
+ return { cursorCount, claudeCount, claudeSection, agentsSection };
483
+ }
@@ -0,0 +1,200 @@
1
+ export class FakeSlackClient {
2
+ constructor() {
3
+ this.botUserId = 'U_GHL_AW';
4
+ this.botBotId = 'B_GHL_AW';
5
+ this.seq = 1;
6
+ this.messages = [];
7
+ this.uploads = [];
8
+ this.unfurls = [];
9
+
10
+ this.auth = {
11
+ test: async () => ({ user_id: this.botUserId, bot_id: this.botBotId }),
12
+ };
13
+
14
+ this.chat = {
15
+ postMessage: async (payload) => this.postMessage(payload),
16
+ update: async (payload) => this.updateMessage(payload),
17
+ delete: async (payload) => this.deleteMessage(payload),
18
+ unfurl: async (payload) => {
19
+ this.unfurls.push(payload);
20
+ return { ok: true };
21
+ },
22
+ };
23
+
24
+ this.conversations = {
25
+ replies: async ({ channel, ts }) => ({ messages: this.getThreadMessages(channel, ts) }),
26
+ };
27
+
28
+ this.reactions = {
29
+ add: async ({ channel, name, timestamp }) => {
30
+ const msg = this.findMessage(channel, timestamp);
31
+ if (msg) msg.reactions.add(name);
32
+ return { ok: true };
33
+ },
34
+ remove: async ({ channel, name, timestamp }) => {
35
+ const msg = this.findMessage(channel, timestamp);
36
+ if (msg) msg.reactions.delete(name);
37
+ return { ok: true };
38
+ },
39
+ };
40
+
41
+ this.files = {
42
+ sharedPublicURL: async ({ file }) => ({ ok: true, file: { permalink_public: `https://slack-files.com/public-${file}` } }),
43
+ info: async ({ file }) => ({ ok: true, file: this.findFile(file) }),
44
+ uploadV2: async (payload) => this.uploadFile(payload),
45
+ };
46
+ }
47
+
48
+ nextTs() {
49
+ const ts = `${1000 + Math.floor(this.seq / 1000)}.${String(this.seq).padStart(3, '0')}`;
50
+ this.seq += 1;
51
+ return ts;
52
+ }
53
+
54
+ normalizeFile(file = {}) {
55
+ const name = file.name || file.title || `file-${this.seq}`;
56
+ const mimetype = file.mimetype || (file.filetype ? `image/${file.filetype}` : 'application/octet-stream');
57
+ const contentBase64 = file.contentBase64 || file.data;
58
+ return {
59
+ id: file.id || `F${this.seq}`,
60
+ name,
61
+ title: file.title,
62
+ mimetype,
63
+ filetype: file.filetype || name.split('.').pop() || 'bin',
64
+ permalink: file.permalink || `https://slack.local/files/${name}`,
65
+ permalink_public: file.permalink_public,
66
+ original_w: file.original_w,
67
+ original_h: file.original_h,
68
+ plain_text: file.plain_text,
69
+ preview: file.preview,
70
+ size: file.size || (contentBase64 ? Math.round((contentBase64.length * 3) / 4) : undefined),
71
+ url_private_download: file.url_private_download || (contentBase64 ? `data:${mimetype};base64,${contentBase64}` : undefined),
72
+ url_private: file.url_private || (contentBase64 ? `data:${mimetype};base64,${contentBase64}` : undefined),
73
+ };
74
+ }
75
+
76
+ addIncomingMessage({ channel, user, text, ts, thread_ts, files = [], blocks = [], attachments = [], subtype, bot_id }) {
77
+ const message = {
78
+ channel,
79
+ user,
80
+ text,
81
+ ts: ts || this.nextTs(),
82
+ thread_ts,
83
+ subtype,
84
+ bot_id,
85
+ blocks,
86
+ attachments,
87
+ files: files.map((f) => this.normalizeFile(f)),
88
+ reactions: new Set(),
89
+ };
90
+ this.messages.push(message);
91
+ return message;
92
+ }
93
+
94
+ postMessage(payload) {
95
+ const message = {
96
+ channel: payload.channel,
97
+ user: this.botUserId,
98
+ bot_id: this.botBotId,
99
+ text: payload.text || '',
100
+ ts: payload.ts || this.nextTs(),
101
+ thread_ts: payload.thread_ts,
102
+ blocks: payload.blocks || [],
103
+ attachments: [],
104
+ files: [],
105
+ reactions: new Set(),
106
+ };
107
+ this.messages.push(message);
108
+ return { ok: true, ts: message.ts };
109
+ }
110
+
111
+ updateMessage(payload) {
112
+ const msg = this.findMessage(payload.channel, payload.ts);
113
+ if (msg) {
114
+ msg.text = payload.text || msg.text;
115
+ msg.blocks = payload.blocks || msg.blocks;
116
+ }
117
+ return { ok: true, ts: payload.ts };
118
+ }
119
+
120
+ deleteMessage(payload) {
121
+ this.messages = this.messages.filter((m) => !(m.channel === payload.channel && m.ts === payload.ts));
122
+ return { ok: true };
123
+ }
124
+
125
+ uploadFile(payload) {
126
+ const upload = {
127
+ channel: payload.channel_id,
128
+ thread_ts: payload.thread_ts,
129
+ filename: payload.filename,
130
+ title: payload.title,
131
+ sizeBytes: payload.file?.length || 0,
132
+ };
133
+ this.uploads.push(upload);
134
+ this.messages.push({
135
+ channel: payload.channel_id,
136
+ user: this.botUserId,
137
+ bot_id: this.botBotId,
138
+ text: `:paperclip: uploaded ${payload.filename}`,
139
+ ts: this.nextTs(),
140
+ thread_ts: payload.thread_ts,
141
+ blocks: [],
142
+ attachments: [],
143
+ files: [],
144
+ reactions: new Set(),
145
+ });
146
+ return { ok: true };
147
+ }
148
+
149
+ findMessage(channel, ts) {
150
+ return this.messages.find((m) => m.channel === channel && m.ts === ts);
151
+ }
152
+
153
+ findFile(fileId) {
154
+ for (const message of this.messages) {
155
+ const file = message.files?.find((f) => f.id === fileId);
156
+ if (file) return file;
157
+ }
158
+ return null;
159
+ }
160
+
161
+ getThreadMessages(channel, rootTs) {
162
+ return this.messages
163
+ .filter((m) => m.channel === channel && (m.ts === rootTs || m.thread_ts === rootTs))
164
+ .sort((a, b) => Number(a.ts) - Number(b.ts))
165
+ .map((m) => ({
166
+ user: m.user,
167
+ bot_id: m.bot_id,
168
+ text: m.text,
169
+ ts: m.ts,
170
+ thread_ts: m.thread_ts,
171
+ blocks: m.blocks,
172
+ attachments: m.attachments,
173
+ files: m.files,
174
+ subtype: m.subtype,
175
+ }));
176
+ }
177
+
178
+ latestThreadMessages(channel, threadTs) {
179
+ return this.getThreadMessages(channel, threadTs).map((m) => ({
180
+ ts: m.ts,
181
+ user: m.user || m.bot_id,
182
+ text: m.text,
183
+ }));
184
+ }
185
+
186
+ findLastBotMessageWithAction(channel, threadTs, actionId) {
187
+ const messages = this.messages
188
+ .filter((m) => m.channel === channel && (m.ts === threadTs || m.thread_ts === threadTs))
189
+ .filter((m) => m.bot_id === this.botBotId)
190
+ .reverse();
191
+ for (const msg of messages) {
192
+ for (const block of msg.blocks || []) {
193
+ for (const el of block.elements || []) {
194
+ if (el.action_id === actionId) return { message: msg, action: el };
195
+ }
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ }