efarmz-slackbot-data 1.0.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.
Files changed (131) hide show
  1. package/.clever.json +12 -0
  2. package/.dockerignore +13 -0
  3. package/.env.example +28 -0
  4. package/.github/workflows/deploy-production.yaml +34 -0
  5. package/.prettierrc +6 -0
  6. package/.tasks/F1-bootstrap.md +110 -0
  7. package/.tasks/F2-domain-layer.md +173 -0
  8. package/.tasks/F3-application-layer.md +166 -0
  9. package/.tasks/F4-infrastructure-layer.md +229 -0
  10. package/.tasks/F5-config-main.md +160 -0
  11. package/.tasks/F6-schemas-deployment.md +129 -0
  12. package/CLAUDE.md +163 -0
  13. package/Dockerfile +15 -0
  14. package/PRD.md +119 -0
  15. package/docs/schemas/.gitkeep +0 -0
  16. package/docs/schemas/_guidelines.md +89 -0
  17. package/docs/schemas/efarmz_db.md +759 -0
  18. package/docs/schemas/example.md +16 -0
  19. package/eslint.config.mjs +18 -0
  20. package/package.json +54 -0
  21. package/releaserc.json +15 -0
  22. package/src/.gitkeep +0 -0
  23. package/src/application/agent/.gitkeep +0 -0
  24. package/src/application/agent/AgentContext.test.ts +263 -0
  25. package/src/application/agent/AgentContext.ts +93 -0
  26. package/src/application/agent/AgentLoop.test.ts +275 -0
  27. package/src/application/agent/AgentLoop.ts +101 -0
  28. package/src/application/agent/AgentRunResult.ts +11 -0
  29. package/src/application/agent/LLMMessage.ts +16 -0
  30. package/src/application/agent/tools/RunSqlTool.ts +23 -0
  31. package/src/application/formatting/.gitkeep +0 -0
  32. package/src/application/formatting/CsvRenderer.test.ts +162 -0
  33. package/src/application/formatting/CsvRenderer.ts +34 -0
  34. package/src/application/formatting/MonospaceTableRenderer.test.ts +129 -0
  35. package/src/application/formatting/MonospaceTableRenderer.ts +58 -0
  36. package/src/application/formatting/RenderedResponse.ts +7 -0
  37. package/src/application/formatting/ResponseRenderer.test.ts +159 -0
  38. package/src/application/formatting/ResponseRenderer.ts +39 -0
  39. package/src/application/formatting/ScalarRenderer.test.ts +36 -0
  40. package/src/application/formatting/ScalarRenderer.ts +12 -0
  41. package/src/application/usecases/.gitkeep +0 -0
  42. package/src/application/usecases/AnswerQuestion.test.ts +362 -0
  43. package/src/application/usecases/AnswerQuestion.ts +69 -0
  44. package/src/application/usecases/ParseQuestion.test.ts +39 -0
  45. package/src/application/usecases/ParseQuestion.ts +9 -0
  46. package/src/config/.gitkeep +0 -0
  47. package/src/config/Container.test.ts +35 -0
  48. package/src/config/Container.ts +74 -0
  49. package/src/config/constants.ts +9 -0
  50. package/src/config/env.test.ts +103 -0
  51. package/src/config/env.ts +41 -0
  52. package/src/domain/entities/.gitkeep +0 -0
  53. package/src/domain/entities/Conversation.test.ts +69 -0
  54. package/src/domain/entities/Conversation.ts +26 -0
  55. package/src/domain/entities/ConversationMessage.test.ts +49 -0
  56. package/src/domain/entities/ConversationMessage.ts +18 -0
  57. package/src/domain/entities/index.ts +2 -0
  58. package/src/domain/errors/.gitkeep +0 -0
  59. package/src/domain/errors/AgentLoopExceededError.ts +12 -0
  60. package/src/domain/errors/DomainError.test.ts +106 -0
  61. package/src/domain/errors/DomainError.ts +11 -0
  62. package/src/domain/errors/InvalidSqlError.ts +15 -0
  63. package/src/domain/errors/LLMError.ts +15 -0
  64. package/src/domain/errors/SchemaLoadError.ts +15 -0
  65. package/src/domain/errors/SqlExecutionError.ts +15 -0
  66. package/src/domain/errors/index.ts +15 -0
  67. package/src/domain/ports/.gitkeep +0 -0
  68. package/src/domain/ports/AdminLogger.ts +16 -0
  69. package/src/domain/ports/ConversationRepository.ts +10 -0
  70. package/src/domain/ports/LLMProvider.ts +33 -0
  71. package/src/domain/ports/Logger.ts +8 -0
  72. package/src/domain/ports/SchemaCatalog.ts +5 -0
  73. package/src/domain/ports/SlackMessenger.ts +8 -0
  74. package/src/domain/ports/SqlExecutor.ts +8 -0
  75. package/src/domain/ports/SqlValidator.ts +5 -0
  76. package/src/domain/ports/index.ts +17 -0
  77. package/src/domain/value-objects/.gitkeep +0 -0
  78. package/src/domain/value-objects/LLMProviderName.ts +6 -0
  79. package/src/domain/value-objects/QueryResult.test.ts +51 -0
  80. package/src/domain/value-objects/QueryResult.ts +18 -0
  81. package/src/domain/value-objects/Question.test.ts +59 -0
  82. package/src/domain/value-objects/Question.ts +22 -0
  83. package/src/domain/value-objects/QuestionFlags.test.ts +59 -0
  84. package/src/domain/value-objects/QuestionFlags.ts +18 -0
  85. package/src/domain/value-objects/ResponseRendering.ts +7 -0
  86. package/src/domain/value-objects/SqlQuery.test.ts +40 -0
  87. package/src/domain/value-objects/SqlQuery.ts +12 -0
  88. package/src/domain/value-objects/ThreadId.test.ts +68 -0
  89. package/src/domain/value-objects/ThreadId.ts +27 -0
  90. package/src/domain/value-objects/index.ts +13 -0
  91. package/src/infrastructure/llm/.gitkeep +0 -0
  92. package/src/infrastructure/llm/AnthropicLLMProvider.test.ts +229 -0
  93. package/src/infrastructure/llm/AnthropicLLMProvider.ts +45 -0
  94. package/src/infrastructure/llm/index.ts +4 -0
  95. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.test.ts +173 -0
  96. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.ts +34 -0
  97. package/src/infrastructure/llm/prompts/SystemPromptBuilder.test.ts +41 -0
  98. package/src/infrastructure/llm/prompts/SystemPromptBuilder.ts +31 -0
  99. package/src/infrastructure/llm/prompts/ToolDefinitions.ts +7 -0
  100. package/src/infrastructure/logging/.gitkeep +0 -0
  101. package/src/infrastructure/logging/PinoLogger.test.ts +59 -0
  102. package/src/infrastructure/logging/PinoLogger.ts +28 -0
  103. package/src/infrastructure/logging/index.ts +1 -0
  104. package/src/infrastructure/persistence/.gitkeep +0 -0
  105. package/src/infrastructure/persistence/InMemoryConversationRepository.test.ts +325 -0
  106. package/src/infrastructure/persistence/InMemoryConversationRepository.ts +69 -0
  107. package/src/infrastructure/persistence/PostgresPoolFactory.ts +11 -0
  108. package/src/infrastructure/persistence/PostgresSqlExecutor.test.ts +130 -0
  109. package/src/infrastructure/persistence/PostgresSqlExecutor.ts +34 -0
  110. package/src/infrastructure/persistence/index.ts +3 -0
  111. package/src/infrastructure/schemas/.gitkeep +0 -0
  112. package/src/infrastructure/schemas/FileSystemSchemaCatalog.test.ts +163 -0
  113. package/src/infrastructure/schemas/FileSystemSchemaCatalog.ts +35 -0
  114. package/src/infrastructure/schemas/index.ts +4 -0
  115. package/src/infrastructure/slack/.gitkeep +0 -0
  116. package/src/infrastructure/slack/BoltSlackMessenger.test.ts +59 -0
  117. package/src/infrastructure/slack/BoltSlackMessenger.ts +36 -0
  118. package/src/infrastructure/slack/SlackAdminLogger.test.ts +54 -0
  119. package/src/infrastructure/slack/SlackAdminLogger.ts +27 -0
  120. package/src/infrastructure/slack/SlackApp.ts +9 -0
  121. package/src/infrastructure/slack/handlers/AppMentionHandler.ts +52 -0
  122. package/src/infrastructure/slack/handlers/DirectMessageHandler.ts +65 -0
  123. package/src/infrastructure/slack/index.ts +5 -0
  124. package/src/infrastructure/sql/.gitkeep +0 -0
  125. package/src/infrastructure/sql/RegexSqlValidator.test.ts +242 -0
  126. package/src/infrastructure/sql/RegexSqlValidator.ts +53 -0
  127. package/src/infrastructure/sql/index.ts +1 -0
  128. package/src/main.ts +19 -0
  129. package/tsconfig.json +23 -0
  130. package/vitest.config.ts +15 -0
  131. package/vitest.setup.ts +23 -0
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import FileSystemSchemaCatalog from "./FileSystemSchemaCatalog.js";
3
+ import SchemaLoadError from "@/domain/errors/SchemaLoadError.js";
4
+ import {
5
+ mkdtempSync,
6
+ writeFileSync,
7
+ rmSync,
8
+ chmodSync,
9
+ } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+
13
+ describe("FileSystemSchemaCatalog", () => {
14
+ let tempDir: string;
15
+
16
+ beforeEach(() => {
17
+ tempDir = mkdtempSync(join(tmpdir(), `schema-test-`));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("valid scenarios", () => {
25
+ it("loads and concatenates multiple markdown files", () => {
26
+ writeFileSync(join(tempDir, `schema1.md`), `Schema 1 content`);
27
+ writeFileSync(join(tempDir, `schema2.md`), `Schema 2 content`);
28
+
29
+ const catalog = new FileSystemSchemaCatalog(tempDir);
30
+ const documentation = catalog.getDocumentation();
31
+
32
+ expect(documentation).toContain(`--- schema1.md ---`);
33
+ expect(documentation).toContain(`Schema 1 content`);
34
+ expect(documentation).toContain(`--- schema2.md ---`);
35
+ expect(documentation).toContain(`Schema 2 content`);
36
+ expect(documentation.indexOf(`schema1.md`)).toBeLessThan(
37
+ documentation.indexOf(`schema2.md`),
38
+ );
39
+ });
40
+
41
+ it("separates files with double newlines", () => {
42
+ writeFileSync(join(tempDir, `a.md`), `Content A`);
43
+ writeFileSync(join(tempDir, `b.md`), `Content B`);
44
+
45
+ const catalog = new FileSystemSchemaCatalog(tempDir);
46
+ const documentation = catalog.getDocumentation();
47
+
48
+ expect(documentation).toMatch(/Content A\n\n--- b\.md ---/);
49
+ });
50
+
51
+ it("returns empty string for directory with no markdown files", () => {
52
+ writeFileSync(join(tempDir, `readme.txt`), `Not a markdown`);
53
+ writeFileSync(join(tempDir, `data.json`), `{"key": "value"}`);
54
+
55
+ const catalog = new FileSystemSchemaCatalog(tempDir);
56
+ expect(catalog.getDocumentation()).toBe(``);
57
+ });
58
+
59
+ it("returns empty string for empty directory", () => {
60
+ const catalog = new FileSystemSchemaCatalog(tempDir);
61
+ expect(catalog.getDocumentation()).toBe(``);
62
+ });
63
+
64
+ it("sorts files alphabetically", () => {
65
+ writeFileSync(join(tempDir, `zebra.md`), `Z content`);
66
+ writeFileSync(join(tempDir, `apple.md`), `A content`);
67
+ writeFileSync(join(tempDir, `banana.md`), `B content`);
68
+
69
+ const catalog = new FileSystemSchemaCatalog(tempDir);
70
+ const documentation = catalog.getDocumentation();
71
+
72
+ const appleIdx = documentation.indexOf(`apple.md`);
73
+ const bananaIdx = documentation.indexOf(`banana.md`);
74
+ const zebraIdx = documentation.indexOf(`zebra.md`);
75
+
76
+ expect(appleIdx).toBeLessThan(bananaIdx);
77
+ expect(bananaIdx).toBeLessThan(zebraIdx);
78
+ });
79
+
80
+ it("memoizes documentation (same result on repeated calls)", () => {
81
+ writeFileSync(join(tempDir, `schema.md`), `Content`);
82
+
83
+ const catalog = new FileSystemSchemaCatalog(tempDir);
84
+ const result1 = catalog.getDocumentation();
85
+ const result2 = catalog.getDocumentation();
86
+
87
+ expect(result1).toBe(result2);
88
+ expect(result1).toBe(catalog.getDocumentation());
89
+ });
90
+
91
+ it("handles markdown files with newlines and special characters", () => {
92
+ const complexContent = `# Schema
93
+
94
+ \`\`\`sql
95
+ SELECT * FROM users
96
+ WHERE email LIKE '%@example.com%'
97
+ \`\`\`
98
+
99
+ ## Description
100
+ This is a test schema.`;
101
+ writeFileSync(join(tempDir, `complex.md`), complexContent);
102
+
103
+ const catalog = new FileSystemSchemaCatalog(tempDir);
104
+ const documentation = catalog.getDocumentation();
105
+
106
+ expect(documentation).toContain(complexContent);
107
+ });
108
+ });
109
+
110
+ describe("error scenarios", () => {
111
+ it("throws SchemaLoadError when directory does not exist", () => {
112
+ const nonexistentDir = join(tempDir, `does-not-exist`);
113
+
114
+ expect(() => {
115
+ new FileSystemSchemaCatalog(nonexistentDir);
116
+ }).toThrow(SchemaLoadError);
117
+ });
118
+
119
+ it("SchemaLoadError contains directory name and reason", () => {
120
+ const nonexistentDir = join(tempDir, `missing`);
121
+
122
+ try {
123
+ new FileSystemSchemaCatalog(nonexistentDir);
124
+ } catch (error) {
125
+ expect(error).toBeInstanceOf(SchemaLoadError);
126
+ if (error instanceof SchemaLoadError) {
127
+ expect(error.schemaName).toBe(nonexistentDir);
128
+ expect(error.reason).toBeTruthy();
129
+ }
130
+ }
131
+ });
132
+
133
+ it("throws SchemaLoadError when a markdown file cannot be read", () => {
134
+ const mdFile = join(tempDir, `unreadable.md`);
135
+ writeFileSync(mdFile, `Content`);
136
+
137
+ // Remove read permissions
138
+ chmodSync(mdFile, 0o000);
139
+
140
+ try {
141
+ expect(() => {
142
+ new FileSystemSchemaCatalog(tempDir);
143
+ }).toThrow(SchemaLoadError);
144
+ } finally {
145
+ // Restore permissions for cleanup
146
+ chmodSync(mdFile, 0o644);
147
+ }
148
+ });
149
+
150
+ it("has correct error code", () => {
151
+ const nonexistentDir = join(tempDir, `missing`);
152
+
153
+ try {
154
+ new FileSystemSchemaCatalog(nonexistentDir);
155
+ } catch (error) {
156
+ expect(error).toBeInstanceOf(SchemaLoadError);
157
+ if (error instanceof SchemaLoadError) {
158
+ expect(error.code).toBe(`SCHEMA_LOAD_ERROR`);
159
+ }
160
+ }
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,35 @@
1
+ import { readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import type SchemaCatalog from "@/domain/ports/SchemaCatalog.js";
4
+ import SchemaLoadError from "@/domain/errors/SchemaLoadError.js";
5
+
6
+ class FileSystemSchemaCatalog implements SchemaCatalog {
7
+ private readonly documentation: string;
8
+
9
+ constructor(schemasDir: string) {
10
+ try {
11
+ const files = readdirSync(schemasDir)
12
+ .filter((f) => f.endsWith(`.md`))
13
+ .sort();
14
+
15
+ const contents = files.map((file) => {
16
+ const filePath = join(schemasDir, file);
17
+ const content = readFileSync(filePath, `utf-8`);
18
+ return `--- ${file} ---\n${content}`;
19
+ });
20
+
21
+ this.documentation = contents.join(`\n\n`);
22
+ } catch (error) {
23
+ throw new SchemaLoadError(
24
+ schemasDir,
25
+ error instanceof Error ? error.message : String(error),
26
+ );
27
+ }
28
+ }
29
+
30
+ getDocumentation(): string {
31
+ return this.documentation;
32
+ }
33
+ }
34
+
35
+ export default FileSystemSchemaCatalog;
@@ -0,0 +1,4 @@
1
+ import FileSystemSchemaCatalog from "./FileSystemSchemaCatalog.js";
2
+
3
+ export { FileSystemSchemaCatalog };
4
+ export default FileSystemSchemaCatalog;
File without changes
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import BoltSlackMessenger from "./BoltSlackMessenger.js";
3
+ import ThreadId from "@/domain/value-objects/ThreadId.js";
4
+
5
+ describe(`BoltSlackMessenger`, () => {
6
+ it(`should post message to channel and thread`, async () => {
7
+ const mockClient = {
8
+ chat: {
9
+ postMessage: vi.fn(),
10
+ },
11
+ } as any;
12
+
13
+ const messenger = new BoltSlackMessenger(mockClient);
14
+ const threadId = ThreadId.create(`C123`, `1234567890.000001`);
15
+
16
+ await messenger.post(threadId, `Hello world`);
17
+
18
+ expect(mockClient.chat.postMessage).toHaveBeenCalledWith({
19
+ channel: `C123`,
20
+ thread_ts: `1234567890.000001`,
21
+ text: `Hello world`,
22
+ });
23
+ });
24
+
25
+ it(`should post message and upload CSV file`, async () => {
26
+ const mockClient = {
27
+ chat: {
28
+ postMessage: vi.fn(),
29
+ },
30
+ files: {
31
+ uploadV2: vi.fn(),
32
+ },
33
+ } as any;
34
+
35
+ const messenger = new BoltSlackMessenger(mockClient);
36
+ const threadId = ThreadId.create(`C123`, `1234567890.000001`);
37
+ const csvBuffer = Buffer.from(`col1,col2\nval1,val2`);
38
+
39
+ await messenger.postCsv(
40
+ threadId,
41
+ `Results`,
42
+ csvBuffer,
43
+ `results.csv`,
44
+ );
45
+
46
+ expect(mockClient.chat.postMessage).toHaveBeenCalledWith({
47
+ channel: `C123`,
48
+ thread_ts: `1234567890.000001`,
49
+ text: `Results`,
50
+ });
51
+
52
+ expect(mockClient.files.uploadV2).toHaveBeenCalledWith({
53
+ channel_id: `C123`,
54
+ thread_ts: `1234567890.000001`,
55
+ filename: `results.csv`,
56
+ file: csvBuffer,
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,36 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ import type SlackMessenger from "@/domain/ports/SlackMessenger.js";
3
+ import type ThreadId from "@/domain/value-objects/ThreadId.js";
4
+
5
+ class BoltSlackMessenger implements SlackMessenger {
6
+ constructor(private readonly client: WebClient) {}
7
+
8
+ async post(threadId: ThreadId, text: string): Promise<void> {
9
+ await this.client.chat.postMessage({
10
+ channel: threadId.channelId,
11
+ thread_ts: threadId.threadTs,
12
+ text,
13
+ });
14
+ }
15
+
16
+ async postCsv(
17
+ threadId: ThreadId,
18
+ text: string,
19
+ csvBuffer: Buffer,
20
+ filename: string,
21
+ ): Promise<void> {
22
+ await this.client.chat.postMessage({
23
+ channel: threadId.channelId,
24
+ thread_ts: threadId.threadTs,
25
+ text,
26
+ });
27
+ await this.client.files.uploadV2({
28
+ channel_id: threadId.channelId,
29
+ thread_ts: threadId.threadTs,
30
+ filename,
31
+ file: csvBuffer,
32
+ });
33
+ }
34
+ }
35
+
36
+ export default BoltSlackMessenger;
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import SlackAdminLogger from "./SlackAdminLogger.js";
3
+ import type { AdminLogEntry } from "@/domain/ports/AdminLogger.js";
4
+
5
+ describe(`SlackAdminLogger`, () => {
6
+ it(`should log query to admin channel when configured`, async () => {
7
+ const mockClient = {
8
+ chat: {
9
+ postMessage: vi.fn(),
10
+ },
11
+ } as any;
12
+
13
+ const logger = new SlackAdminLogger(mockClient, `C_ADMIN`);
14
+ const entry: AdminLogEntry = {
15
+ userId: `U123`,
16
+ question: `How many users?`,
17
+ sqls: [`SELECT COUNT(*) FROM users`],
18
+ rowCount: 42,
19
+ durationMs: 123,
20
+ inputTokens: 100,
21
+ outputTokens: 50,
22
+ };
23
+
24
+ await logger.logQuery(entry);
25
+
26
+ expect(mockClient.chat.postMessage).toHaveBeenCalledWith({
27
+ channel: `C_ADMIN`,
28
+ text: expect.stringContaining(`U123`),
29
+ });
30
+ });
31
+
32
+ it(`should not log when admin channel is not configured`, async () => {
33
+ const mockClient = {
34
+ chat: {
35
+ postMessage: vi.fn(),
36
+ },
37
+ } as any;
38
+
39
+ const logger = new SlackAdminLogger(mockClient, null);
40
+ const entry: AdminLogEntry = {
41
+ userId: `U123`,
42
+ question: `How many users?`,
43
+ sqls: [`SELECT COUNT(*) FROM users`],
44
+ rowCount: 42,
45
+ durationMs: 123,
46
+ inputTokens: 100,
47
+ outputTokens: 50,
48
+ };
49
+
50
+ await logger.logQuery(entry);
51
+
52
+ expect(mockClient.chat.postMessage).not.toHaveBeenCalled();
53
+ });
54
+ });
@@ -0,0 +1,27 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ import type AdminLogger from "@/domain/ports/AdminLogger.js";
3
+ import type { AdminLogEntry } from "@/domain/ports/AdminLogger.js";
4
+
5
+ class SlackAdminLogger implements AdminLogger {
6
+ constructor(
7
+ private readonly client: WebClient,
8
+ private readonly adminChannelId: string | null,
9
+ ) {}
10
+
11
+ async logQuery(entry: AdminLogEntry): Promise<void> {
12
+ if (!this.adminChannelId) return;
13
+
14
+ const text = `*Query from <@${entry.userId}>*\n\`\`\`\n${entry.sqls[0] ?? ``}\n\`\`\`\nRows: ${entry.rowCount} | Duration: ${entry.durationMs}ms | Tokens: ${entry.inputTokens}+${entry.outputTokens}`;
15
+
16
+ try {
17
+ await this.client.chat.postMessage({
18
+ channel: this.adminChannelId,
19
+ text,
20
+ });
21
+ } catch (err) {
22
+ console.error(`[SlackAdminLogger] Failed to post to admin channel:`, err);
23
+ }
24
+ }
25
+ }
26
+
27
+ export default SlackAdminLogger;
@@ -0,0 +1,9 @@
1
+ import slack from "@slack/bolt";
2
+
3
+ function createSlackApp(token: string, signingSecret: string) {
4
+ const receiver = new slack.ExpressReceiver({ signingSecret });
5
+ const app = new slack.App({ token, receiver });
6
+ return { app, receiver };
7
+ }
8
+
9
+ export default createSlackApp;
@@ -0,0 +1,52 @@
1
+ import type { App } from "@slack/bolt";
2
+ import type AnswerQuestion from "@/application/usecases/AnswerQuestion.js";
3
+ import ParseQuestion from "@/application/usecases/ParseQuestion.js";
4
+ import type Logger from "@/domain/ports/Logger.js";
5
+ import ThreadId from "@/domain/value-objects/ThreadId.js";
6
+
7
+ const eventCache = new Map<string, number>();
8
+ const EVENT_CACHE_TTL = 5 * 60 * 1000;
9
+
10
+ function registerAppMentionHandler(
11
+ app: App,
12
+ answerQuestion: AnswerQuestion,
13
+ logger: Logger,
14
+ ): void {
15
+ app.event(
16
+ `app_mention`,
17
+ async (payload: any): Promise<void> => {
18
+ const { event, ack } = payload;
19
+ await ack();
20
+
21
+ const eventId = event.event_ts;
22
+ if (eventCache.has(eventId)) return;
23
+
24
+ eventCache.set(eventId, Date.now());
25
+
26
+ for (const [key, timestamp] of eventCache) {
27
+ if (Date.now() - timestamp > EVENT_CACHE_TTL) {
28
+ eventCache.delete(key);
29
+ }
30
+ }
31
+
32
+ setImmediate(async () => {
33
+ try {
34
+ const userId = event.user;
35
+ if (!userId) return;
36
+
37
+ const threadId = ThreadId.create(
38
+ event.channel,
39
+ event.thread_ts ?? event.ts,
40
+ );
41
+ const question = ParseQuestion.execute(event.text);
42
+
43
+ await answerQuestion.execute(question, threadId, userId);
44
+ } catch (error) {
45
+ logger.error(`app_mention handler error`, { error });
46
+ }
47
+ });
48
+ },
49
+ );
50
+ }
51
+
52
+ export default registerAppMentionHandler;
@@ -0,0 +1,65 @@
1
+ import type { App } from "@slack/bolt";
2
+ import type AnswerQuestion from "@/application/usecases/AnswerQuestion.js";
3
+ import ParseQuestion from "@/application/usecases/ParseQuestion.js";
4
+ import type Logger from "@/domain/ports/Logger.js";
5
+ import ThreadId from "@/domain/value-objects/ThreadId.js";
6
+
7
+ const eventCache = new Map<string, number>();
8
+ const EVENT_CACHE_TTL = 5 * 60 * 1000;
9
+
10
+ interface SimpleMessageEvent {
11
+ type: string;
12
+ user?: string;
13
+ bot_id?: string;
14
+ channel_type?: string;
15
+ channel: string;
16
+ ts: string;
17
+ thread_ts?: string;
18
+ text?: string;
19
+ event_ts: string;
20
+ }
21
+
22
+ function registerDirectMessageHandler(
23
+ app: App,
24
+ answerQuestion: AnswerQuestion,
25
+ logger: Logger,
26
+ ): void {
27
+ app.message(async (payload: any): Promise<void> => {
28
+ const { event } = payload;
29
+ const messageEvent = event as SimpleMessageEvent;
30
+
31
+ if (!messageEvent.user || messageEvent.bot_id) return;
32
+
33
+ if (messageEvent.channel_type !== `im`) return;
34
+
35
+ const eventId = messageEvent.event_ts;
36
+ if (eventCache.has(eventId)) return;
37
+
38
+ eventCache.set(eventId, Date.now());
39
+
40
+ for (const [key, timestamp] of eventCache) {
41
+ if (Date.now() - timestamp > EVENT_CACHE_TTL) {
42
+ eventCache.delete(key);
43
+ }
44
+ }
45
+
46
+ setImmediate(async () => {
47
+ try {
48
+ const userId = messageEvent.user;
49
+ if (!userId || !messageEvent.text) return;
50
+
51
+ const threadId = ThreadId.create(
52
+ messageEvent.channel,
53
+ messageEvent.thread_ts ?? messageEvent.ts,
54
+ );
55
+ const question = ParseQuestion.execute(messageEvent.text);
56
+
57
+ await answerQuestion.execute(question, threadId, userId);
58
+ } catch (error) {
59
+ logger.error(`direct_message handler error`, { error });
60
+ }
61
+ });
62
+ });
63
+ }
64
+
65
+ export default registerDirectMessageHandler;
@@ -0,0 +1,5 @@
1
+ export { default as createSlackApp } from "./SlackApp.js";
2
+ export { default as BoltSlackMessenger } from "./BoltSlackMessenger.js";
3
+ export { default as SlackAdminLogger } from "./SlackAdminLogger.js";
4
+ export { default as registerAppMentionHandler } from "./handlers/AppMentionHandler.js";
5
+ export { default as registerDirectMessageHandler } from "./handlers/DirectMessageHandler.js";
File without changes