@interactive-inc/claude-funnel 0.2.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/lib/factory.ts +10 -0
  4. package/lib/funnel.ts +51 -0
  5. package/lib/index.ts +86 -0
  6. package/lib/modules/agents/funnel-agents.ts +105 -0
  7. package/lib/modules/channels/funnel-channels.ts +113 -0
  8. package/lib/modules/claude/funnel-claude.ts +136 -0
  9. package/lib/modules/connectors/funnel-connector-adapter.ts +9 -0
  10. package/lib/modules/connectors/funnel-connector-listener.ts +5 -0
  11. package/lib/modules/connectors/funnel-connectors.ts +124 -0
  12. package/lib/modules/connectors/funnel-discord-adapter.ts +56 -0
  13. package/lib/modules/connectors/funnel-discord-event-processor.ts +48 -0
  14. package/lib/modules/connectors/funnel-discord-listener.ts +65 -0
  15. package/lib/modules/connectors/funnel-gh-adapter.ts +51 -0
  16. package/lib/modules/connectors/funnel-gh-listener.ts +102 -0
  17. package/lib/modules/connectors/funnel-slack-adapter.ts +31 -0
  18. package/lib/modules/connectors/funnel-slack-event-processor.ts +91 -0
  19. package/lib/modules/connectors/funnel-slack-listener.ts +72 -0
  20. package/lib/modules/connectors/resolve-listener.ts +13 -0
  21. package/lib/modules/fs/funnel-file-system.ts +14 -0
  22. package/lib/modules/fs/memory-funnel-file-system.ts +85 -0
  23. package/lib/modules/fs/node-funnel-file-system.ts +56 -0
  24. package/lib/modules/gateway/daemon.ts +190 -0
  25. package/lib/modules/gateway/funnel-broadcaster.ts +37 -0
  26. package/lib/modules/gateway/funnel-event-logger.ts +59 -0
  27. package/lib/modules/gateway/funnel-gateway.ts +166 -0
  28. package/lib/modules/gateway/kill-competing-slack-gateways.ts +52 -0
  29. package/lib/modules/http/funnel-http-client.ts +17 -0
  30. package/lib/modules/http/memory-funnel-http-client.ts +40 -0
  31. package/lib/modules/http/node-funnel-http-client.ts +27 -0
  32. package/lib/modules/logger.ts +26 -0
  33. package/lib/modules/mcp/channel-server.ts +77 -0
  34. package/lib/modules/mcp/funnel-mcp.ts +107 -0
  35. package/lib/modules/process/funnel-process-runner.ts +28 -0
  36. package/lib/modules/process/memory-funnel-process-runner.ts +88 -0
  37. package/lib/modules/process/node-funnel-process-runner.ts +100 -0
  38. package/lib/modules/repos/funnel-repositories.ts +107 -0
  39. package/lib/modules/router/query-to-cli-args.ts +20 -0
  40. package/lib/modules/router/to-request.ts +122 -0
  41. package/lib/modules/router/validator.ts +27 -0
  42. package/lib/modules/settings/funnel-settings-reader.ts +6 -0
  43. package/lib/modules/settings/funnel-settings-store.ts +57 -0
  44. package/lib/modules/settings/mock-funnel-settings-reader.ts +27 -0
  45. package/lib/modules/settings/settings-schema.ts +67 -0
  46. package/lib/modules/tui/app.tsx +44 -0
  47. package/lib/modules/tui/tui.tsx +13 -0
  48. package/lib/routes/agents/add.help.ts +3 -0
  49. package/lib/routes/agents/add.ts +33 -0
  50. package/lib/routes/agents/group.help.ts +13 -0
  51. package/lib/routes/agents/group.ts +25 -0
  52. package/lib/routes/agents/launch.help.ts +3 -0
  53. package/lib/routes/agents/launch.ts +35 -0
  54. package/lib/routes/agents/remove.help.ts +3 -0
  55. package/lib/routes/agents/remove.ts +17 -0
  56. package/lib/routes/agents/rename.help.ts +5 -0
  57. package/lib/routes/agents/rename.ts +17 -0
  58. package/lib/routes/agents/routes.ts +17 -0
  59. package/lib/routes/agents/set.help.ts +5 -0
  60. package/lib/routes/agents/set.ts +32 -0
  61. package/lib/routes/channels/add.help.ts +3 -0
  62. package/lib/routes/channels/add.ts +21 -0
  63. package/lib/routes/channels/connectors-attach.help.ts +3 -0
  64. package/lib/routes/channels/connectors-attach.ts +17 -0
  65. package/lib/routes/channels/connectors-detach.help.ts +3 -0
  66. package/lib/routes/channels/connectors-detach.ts +17 -0
  67. package/lib/routes/channels/group.help.ts +16 -0
  68. package/lib/routes/channels/group.ts +22 -0
  69. package/lib/routes/channels/remove.help.ts +3 -0
  70. package/lib/routes/channels/remove.ts +17 -0
  71. package/lib/routes/channels/rename.help.ts +5 -0
  72. package/lib/routes/channels/rename.ts +17 -0
  73. package/lib/routes/channels/routes.ts +19 -0
  74. package/lib/routes/channels/show.help.ts +1 -0
  75. package/lib/routes/channels/show.ts +26 -0
  76. package/lib/routes/claude/claude.help.ts +11 -0
  77. package/lib/routes/claude/claude.ts +39 -0
  78. package/lib/routes/claude/routes.ts +4 -0
  79. package/lib/routes/connectors/add.help.ts +22 -0
  80. package/lib/routes/connectors/add.ts +55 -0
  81. package/lib/routes/connectors/call.help.ts +17 -0
  82. package/lib/routes/connectors/call.ts +43 -0
  83. package/lib/routes/connectors/group.help.ts +14 -0
  84. package/lib/routes/connectors/group.ts +18 -0
  85. package/lib/routes/connectors/remove.help.ts +3 -0
  86. package/lib/routes/connectors/remove.ts +17 -0
  87. package/lib/routes/connectors/rename.help.ts +5 -0
  88. package/lib/routes/connectors/rename.ts +17 -0
  89. package/lib/routes/connectors/routes.ts +19 -0
  90. package/lib/routes/connectors/set.help.ts +8 -0
  91. package/lib/routes/connectors/set.ts +30 -0
  92. package/lib/routes/connectors/show.help.ts +1 -0
  93. package/lib/routes/connectors/show.ts +32 -0
  94. package/lib/routes/gateway/group.help.ts +15 -0
  95. package/lib/routes/gateway/group.ts +28 -0
  96. package/lib/routes/gateway/logs.help.ts +13 -0
  97. package/lib/routes/gateway/logs.ts +100 -0
  98. package/lib/routes/gateway/restart.help.ts +10 -0
  99. package/lib/routes/gateway/restart.ts +35 -0
  100. package/lib/routes/gateway/routes.ts +18 -0
  101. package/lib/routes/gateway/run.help.ts +12 -0
  102. package/lib/routes/gateway/run.ts +35 -0
  103. package/lib/routes/gateway/start.help.ts +15 -0
  104. package/lib/routes/gateway/start.ts +32 -0
  105. package/lib/routes/gateway/status.help.ts +9 -0
  106. package/lib/routes/gateway/status.ts +28 -0
  107. package/lib/routes/gateway/stop.help.ts +8 -0
  108. package/lib/routes/gateway/stop.ts +21 -0
  109. package/lib/routes/repos/add.help.ts +5 -0
  110. package/lib/routes/repos/add.ts +19 -0
  111. package/lib/routes/repos/group.help.ts +11 -0
  112. package/lib/routes/repos/group.ts +18 -0
  113. package/lib/routes/repos/remove.help.ts +3 -0
  114. package/lib/routes/repos/remove.ts +17 -0
  115. package/lib/routes/repos/rename.help.ts +5 -0
  116. package/lib/routes/repos/rename.ts +17 -0
  117. package/lib/routes/repos/routes.ts +17 -0
  118. package/lib/routes/repos/set.help.ts +5 -0
  119. package/lib/routes/repos/set.ts +21 -0
  120. package/lib/routes/repos/show.help.ts +1 -0
  121. package/lib/routes/repos/show.ts +19 -0
  122. package/lib/routes/status/routes.ts +4 -0
  123. package/lib/routes/status/status.help.ts +6 -0
  124. package/lib/routes/status/status.ts +77 -0
  125. package/lib/routes.ts +36 -0
  126. package/package.json +65 -0
@@ -0,0 +1,124 @@
1
+ import type { CallInput } from "@/modules/connectors/funnel-connector-adapter"
2
+ import { FunnelDiscordAdapter } from "@/modules/connectors/funnel-discord-adapter"
3
+ import { FunnelGhAdapter } from "@/modules/connectors/funnel-gh-adapter"
4
+ import { FunnelSlackAdapter } from "@/modules/connectors/funnel-slack-adapter"
5
+ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
6
+ import type { ConnectorConfig } from "@/modules/settings/settings-schema"
7
+
8
+ type Deps = {
9
+ store: FunnelSettingsReader
10
+ }
11
+
12
+ type UpdateFields = {
13
+ botToken?: string
14
+ appToken?: string
15
+ pollInterval?: number
16
+ }
17
+
18
+ export class FunnelConnectors {
19
+ private readonly store: FunnelSettingsReader
20
+
21
+ constructor(deps: Deps) {
22
+ this.store = deps.store
23
+ Object.freeze(this)
24
+ }
25
+
26
+ list(): ConnectorConfig[] {
27
+ return this.store.read().connectors
28
+ }
29
+
30
+ get(name: string): ConnectorConfig | null {
31
+ return this.list().find((c) => c.name === name) ?? null
32
+ }
33
+
34
+ add(config: ConnectorConfig): void {
35
+ const settings = this.store.read()
36
+
37
+ if (settings.connectors.some((c) => c.name === config.name)) {
38
+ throw new Error(`connector "${config.name}" already exists`)
39
+ }
40
+
41
+ settings.connectors.push(config)
42
+
43
+ this.store.write(settings)
44
+ }
45
+
46
+ rename(oldName: string, newName: string): void {
47
+ const settings = this.store.read()
48
+
49
+ const connector = settings.connectors.find((c) => c.name === oldName)
50
+
51
+ if (!connector) throw new Error(`connector "${oldName}" not found`)
52
+
53
+ if (settings.connectors.some((c) => c.name === newName)) {
54
+ throw new Error(`connector "${newName}" already exists`)
55
+ }
56
+
57
+ connector.name = newName
58
+
59
+ for (const channel of settings.channels) {
60
+ const index = channel.connectors.indexOf(oldName)
61
+
62
+ if (index >= 0) channel.connectors[index] = newName
63
+ }
64
+
65
+ this.store.write(settings)
66
+ }
67
+
68
+ update(name: string, fields: UpdateFields): void {
69
+ const settings = this.store.read()
70
+
71
+ const connector = settings.connectors.find((c) => c.name === name)
72
+
73
+ if (!connector) throw new Error(`connector "${name}" not found`)
74
+
75
+ if (connector.type === "slack") {
76
+ if (fields.botToken !== undefined) connector.botToken = fields.botToken
77
+ if (fields.appToken !== undefined) connector.appToken = fields.appToken
78
+ } else if (connector.type === "gh") {
79
+ if (fields.pollInterval !== undefined) connector.pollInterval = fields.pollInterval
80
+ } else if (connector.type === "discord") {
81
+ if (fields.botToken !== undefined) connector.botToken = fields.botToken
82
+ }
83
+
84
+ this.store.write(settings)
85
+ }
86
+
87
+ remove(name: string): void {
88
+ const settings = this.store.read()
89
+
90
+ const index = settings.connectors.findIndex((c) => c.name === name)
91
+
92
+ if (index < 0) throw new Error(`connector "${name}" not found`)
93
+
94
+ settings.connectors.splice(index, 1)
95
+
96
+ for (const channel of settings.channels) {
97
+ const ci = channel.connectors.indexOf(name)
98
+
99
+ if (ci >= 0) channel.connectors.splice(ci, 1)
100
+ }
101
+
102
+ this.store.write(settings)
103
+ }
104
+
105
+ async call(name: string, input: CallInput): Promise<unknown> {
106
+ const connector = this.get(name)
107
+
108
+ if (!connector) throw new Error(`connector "${name}" not found`)
109
+
110
+ if (connector.type === "slack") {
111
+ return await new FunnelSlackAdapter({ config: connector }).call(input)
112
+ }
113
+
114
+ if (connector.type === "gh") {
115
+ return await new FunnelGhAdapter().call(input)
116
+ }
117
+
118
+ if (connector.type === "discord") {
119
+ return await new FunnelDiscordAdapter({ config: connector }).call(input)
120
+ }
121
+
122
+ throw new Error(`unsupported connector type: ${(connector as { type: string }).type}`)
123
+ }
124
+ }
@@ -0,0 +1,56 @@
1
+ import {
2
+ FunnelConnectorAdapter,
3
+ type CallInput,
4
+ } from "@/modules/connectors/funnel-connector-adapter"
5
+ import { FunnelHttpClient } from "@/modules/http/funnel-http-client"
6
+ import { NodeFunnelHttpClient } from "@/modules/http/node-funnel-http-client"
7
+ import type { DiscordConnectorConfig } from "@/modules/settings/settings-schema"
8
+
9
+ const DISCORD_API_BASE = "https://discord.com/api/v10"
10
+
11
+ type Deps = {
12
+ config: DiscordConnectorConfig
13
+ http?: FunnelHttpClient
14
+ }
15
+
16
+ const defaultHttp = new NodeFunnelHttpClient()
17
+
18
+ export class FunnelDiscordAdapter extends FunnelConnectorAdapter {
19
+ private readonly token: string
20
+ private readonly http: FunnelHttpClient
21
+
22
+ constructor(deps: Deps) {
23
+ super()
24
+ this.token = deps.config.botToken
25
+ this.http = deps.http ?? defaultHttp
26
+ Object.freeze(this)
27
+ }
28
+
29
+ async call(input: CallInput): Promise<unknown> {
30
+ const method = (input.method || "GET").toUpperCase()
31
+ const path = input.path.startsWith("/") ? input.path : `/${input.path}`
32
+ const hasBody =
33
+ input.body &&
34
+ typeof input.body === "object" &&
35
+ method !== "GET" &&
36
+ Object.keys(input.body as object).length > 0
37
+
38
+ const res = await this.http.fetch({
39
+ method,
40
+ url: `${DISCORD_API_BASE}${path}`,
41
+ headers: {
42
+ Authorization: `Bot ${this.token}`,
43
+ "Content-Type": "application/json",
44
+ },
45
+ body: hasBody ? JSON.stringify(input.body) : undefined,
46
+ })
47
+
48
+ if (!res.ok) {
49
+ throw new Error(`Discord API failed (${res.status}): ${await res.text()}`)
50
+ }
51
+
52
+ if (res.status === 204) return null
53
+
54
+ return await res.json()
55
+ }
56
+ }
@@ -0,0 +1,48 @@
1
+ export type DiscordInboundMessage = {
2
+ authorId: string
3
+ authorIsBot: boolean
4
+ channelId: string
5
+ guildId: string | null
6
+ mentionedUserIds: string[]
7
+ raw: unknown
8
+ }
9
+
10
+ export type DiscordProcessedSkip = { skip: true }
11
+
12
+ export type DiscordProcessedEmit = {
13
+ skip: false
14
+ content: string
15
+ meta: Record<string, string>
16
+ }
17
+
18
+ export type DiscordProcessed = DiscordProcessedSkip | DiscordProcessedEmit
19
+
20
+ type Props = {
21
+ ownUserId: string
22
+ }
23
+
24
+ export class FunnelDiscordEventProcessor {
25
+ private readonly ownUserId: string
26
+
27
+ constructor(props: Props) {
28
+ this.ownUserId = props.ownUserId
29
+ }
30
+
31
+ process(message: DiscordInboundMessage): DiscordProcessed {
32
+ if (message.authorIsBot) return { skip: true }
33
+
34
+ const mentioned = this.ownUserId ? message.mentionedUserIds.includes(this.ownUserId) : false
35
+
36
+ return {
37
+ skip: false,
38
+ content: JSON.stringify(message.raw),
39
+ meta: {
40
+ event_type: "discord",
41
+ channel_id: message.channelId,
42
+ user_id: message.authorId,
43
+ mentioned: String(mentioned),
44
+ guild_id: message.guildId ?? "",
45
+ },
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,65 @@
1
+ import { Client, GatewayIntentBits, Partials } from "discord.js"
2
+ import {
3
+ FunnelConnectorListener,
4
+ type NotifyFn,
5
+ } from "@/modules/connectors/funnel-connector-listener"
6
+ import { FunnelDiscordEventProcessor } from "@/modules/connectors/funnel-discord-event-processor"
7
+ import { logger } from "@/modules/logger"
8
+ import type { DiscordConnectorConfig } from "@/modules/settings/settings-schema"
9
+
10
+ type Deps = {
11
+ config: DiscordConnectorConfig
12
+ }
13
+
14
+ export class FunnelDiscordListener extends FunnelConnectorListener {
15
+ private readonly config: DiscordConnectorConfig
16
+
17
+ constructor(deps: Deps) {
18
+ super()
19
+ this.config = deps.config
20
+ Object.freeze(this)
21
+ }
22
+
23
+ async start(notify: NotifyFn): Promise<void> {
24
+ const client = new Client({
25
+ intents: [
26
+ GatewayIntentBits.Guilds,
27
+ GatewayIntentBits.GuildMessages,
28
+ GatewayIntentBits.MessageContent,
29
+ GatewayIntentBits.DirectMessages,
30
+ ],
31
+ partials: [Partials.Channel],
32
+ })
33
+
34
+ client.on("messageCreate", async (message) => {
35
+ const processor = new FunnelDiscordEventProcessor({ ownUserId: client.user?.id ?? "" })
36
+
37
+ const result = processor.process({
38
+ authorId: message.author.id,
39
+ authorIsBot: message.author.bot,
40
+ channelId: message.channelId,
41
+ guildId: message.guildId,
42
+ mentionedUserIds: [...message.mentions.users.keys()],
43
+ raw: message.toJSON(),
44
+ })
45
+
46
+ if (result.skip) return
47
+
48
+ try {
49
+ await notify(result.content, result.meta)
50
+ } catch (error) {
51
+ logger.error("discord notify error", {
52
+ error: error instanceof Error ? error.message : String(error),
53
+ })
54
+ }
55
+ })
56
+
57
+ client.on("error", (error) => {
58
+ logger.error("discord client error", {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ })
61
+ })
62
+
63
+ await client.login(this.config.botToken)
64
+ }
65
+ }
@@ -0,0 +1,51 @@
1
+ import {
2
+ FunnelConnectorAdapter,
3
+ type CallInput,
4
+ } from "@/modules/connectors/funnel-connector-adapter"
5
+ import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
6
+ import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
7
+
8
+ type Deps = {
9
+ process?: FunnelProcessRunner
10
+ }
11
+
12
+ const defaultProcess = new NodeFunnelProcessRunner()
13
+
14
+ export class FunnelGhAdapter extends FunnelConnectorAdapter {
15
+ private readonly process: FunnelProcessRunner
16
+
17
+ constructor(deps: Deps = {}) {
18
+ super()
19
+ this.process = deps.process ?? defaultProcess
20
+ Object.freeze(this)
21
+ }
22
+
23
+ async call(input: CallInput): Promise<unknown> {
24
+ const args = ["api", input.path]
25
+
26
+ if (input.method && input.method.toLowerCase() !== "get") {
27
+ args.push("-X", input.method.toUpperCase())
28
+ }
29
+
30
+ const hasBody =
31
+ input.body && typeof input.body === "object" && Object.keys(input.body).length > 0
32
+
33
+ if (hasBody) {
34
+ args.push("--input", "-")
35
+ }
36
+
37
+ const result = await this.process.run(["gh", ...args], {
38
+ input: hasBody ? JSON.stringify(input.body) : undefined,
39
+ })
40
+
41
+ if (result.exitCode !== 0) {
42
+ throw new Error(`gh api failed: ${result.stderr.trim() || result.stdout.trim()}`)
43
+ }
44
+
45
+ try {
46
+ return JSON.parse(result.stdout)
47
+ } catch {
48
+ return result.stdout
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ FunnelConnectorListener,
3
+ type NotifyFn,
4
+ } from "@/modules/connectors/funnel-connector-listener"
5
+ import { logger } from "@/modules/logger"
6
+ import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
7
+ import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
8
+ import type { GhConnectorConfig } from "@/modules/settings/settings-schema"
9
+
10
+ type GhNotification = {
11
+ id: string
12
+ reason: string
13
+ subject: { type: string; url: string; title: string }
14
+ repository: { full_name: string }
15
+ updated_at: string
16
+ }
17
+
18
+ type Deps = {
19
+ config: GhConnectorConfig
20
+ process?: FunnelProcessRunner
21
+ }
22
+
23
+ const defaultProcess = new NodeFunnelProcessRunner()
24
+
25
+ const MAX_SEEN = 10000
26
+ const KEEP_SEEN = 5000
27
+
28
+ export class FunnelGhListener extends FunnelConnectorListener {
29
+ private readonly config: GhConnectorConfig
30
+ private readonly process: FunnelProcessRunner
31
+ private readonly seen = new Map<string, string>()
32
+ private bootstrapped = false
33
+ private since = new Date().toISOString()
34
+
35
+ constructor(deps: Deps) {
36
+ super()
37
+ this.config = deps.config
38
+ this.process = deps.process ?? defaultProcess
39
+ }
40
+
41
+ async start(notify: NotifyFn): Promise<void> {
42
+ await this.pollOnce(notify)
43
+
44
+ const interval = this.config.pollInterval ?? 60
45
+
46
+ setInterval(() => void this.pollOnce(notify), interval * 1000).unref()
47
+ }
48
+
49
+ async pollOnce(notify: NotifyFn): Promise<void> {
50
+ const nextSince = new Date().toISOString()
51
+ const params = new URLSearchParams({ since: this.since, all: "false" })
52
+
53
+ try {
54
+ const result = await this.process.run(["gh", "api", `/notifications?${params}`])
55
+
56
+ if (result.exitCode !== 0) {
57
+ logger.error("gh poll failed", { stderr: result.stderr })
58
+ return
59
+ }
60
+
61
+ const items = JSON.parse(result.stdout) as GhNotification[]
62
+
63
+ for (const item of items) {
64
+ if (this.seen.get(item.id) === item.updated_at) continue
65
+
66
+ this.seen.set(item.id, item.updated_at)
67
+
68
+ if (!this.bootstrapped) continue
69
+
70
+ const meta: Record<string, string> = {
71
+ event_type: "gh",
72
+ reason: item.reason,
73
+ subject_type: item.subject.type,
74
+ subject_url: item.subject.url,
75
+ repository: item.repository.full_name,
76
+ thread_id: item.id,
77
+ updated_at: item.updated_at,
78
+ }
79
+
80
+ await notify(JSON.stringify(item), meta)
81
+ }
82
+
83
+ if (this.seen.size > MAX_SEEN) {
84
+ const toDrop = this.seen.size - KEEP_SEEN
85
+ let dropped = 0
86
+
87
+ for (const key of this.seen.keys()) {
88
+ if (dropped >= toDrop) break
89
+ this.seen.delete(key)
90
+ dropped++
91
+ }
92
+ }
93
+
94
+ this.since = nextSince
95
+ this.bootstrapped = true
96
+ } catch (error) {
97
+ logger.error("gh poll error", {
98
+ error: error instanceof Error ? error.message : String(error),
99
+ })
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,31 @@
1
+ import { WebClient } from "@slack/web-api"
2
+ import {
3
+ FunnelConnectorAdapter,
4
+ type CallInput,
5
+ } from "@/modules/connectors/funnel-connector-adapter"
6
+ import type { SlackConnectorConfig } from "@/modules/settings/settings-schema"
7
+
8
+ export type SlackWebClientLike = {
9
+ apiCall: (method: string, options: Record<string, unknown>) => Promise<unknown>
10
+ }
11
+
12
+ type Deps = {
13
+ config: SlackConnectorConfig
14
+ client?: SlackWebClientLike
15
+ }
16
+
17
+ export class FunnelSlackAdapter extends FunnelConnectorAdapter {
18
+ private readonly client: SlackWebClientLike
19
+
20
+ constructor(deps: Deps) {
21
+ super()
22
+ this.client = deps.client ?? new WebClient(deps.config.botToken)
23
+ Object.freeze(this)
24
+ }
25
+
26
+ async call(input: CallInput): Promise<unknown> {
27
+ const body = input.body && typeof input.body === "object" ? input.body : {}
28
+
29
+ return await this.client.apiCall(input.path, body as Record<string, unknown>)
30
+ }
31
+ }
@@ -0,0 +1,91 @@
1
+ export type SlackRawEvent = Record<string, unknown>
2
+
3
+ export type SlackProcessedSkip = { skip: true }
4
+
5
+ export type SlackProcessedEmit = {
6
+ skip: false
7
+ content: string
8
+ meta: Record<string, string>
9
+ shouldReact: boolean
10
+ channel: string
11
+ timestamp: string
12
+ }
13
+
14
+ export type SlackProcessed = SlackProcessedSkip | SlackProcessedEmit
15
+
16
+ const ALLOWED_EVENTS = new Set(["message", "app_mention"])
17
+ const ALLOWED_SUBTYPES = new Set<string | undefined>([
18
+ undefined,
19
+ "thread_broadcast",
20
+ "bot_message",
21
+ "file_share",
22
+ ])
23
+
24
+ const DEDUP_WINDOW = 10_000
25
+
26
+ type Props = {
27
+ ownBotUserId: string
28
+ ownBotId: string
29
+ now?: () => number
30
+ }
31
+
32
+ export class FunnelSlackEventProcessor {
33
+ private readonly ownBotUserId: string
34
+ private readonly ownBotId: string
35
+ private readonly now: () => number
36
+ private readonly dedup = new Map<string, number>()
37
+
38
+ constructor(props: Props) {
39
+ this.ownBotUserId = props.ownBotUserId
40
+ this.ownBotId = props.ownBotId
41
+ this.now = props.now ?? (() => Date.now())
42
+ }
43
+
44
+ process(event: SlackRawEvent): SlackProcessed {
45
+ const eventType = event.type as string | undefined
46
+
47
+ if (!eventType || !ALLOWED_EVENTS.has(eventType)) return { skip: true }
48
+
49
+ const subtype = event.subtype as string | undefined
50
+
51
+ if (!ALLOWED_SUBTYPES.has(subtype)) return { skip: true }
52
+
53
+ const channelId = (event.channel as string) ?? ""
54
+ const eventTs = (event.event_ts as string) ?? (event.ts as string) ?? ""
55
+ const dedupKey = `${channelId}:${eventTs}`
56
+ const now = this.now()
57
+
58
+ if (this.dedup.has(dedupKey)) return { skip: true }
59
+
60
+ this.dedup.set(dedupKey, now)
61
+
62
+ for (const key of this.dedup.keys()) {
63
+ if ((this.dedup.get(key) ?? 0) < now - DEDUP_WINDOW) this.dedup.delete(key)
64
+ }
65
+
66
+ const userId = event.user as string | undefined
67
+ const botId = event.bot_id as string | undefined
68
+
69
+ if (userId === this.ownBotUserId) return { skip: true }
70
+ if (botId === this.ownBotId) return { skip: true }
71
+
72
+ const text = (event.text as string) ?? ""
73
+ const mentioned = text.includes(`<@${this.ownBotUserId}>`)
74
+ const threadTs = (event.thread_ts as string) ?? (event.ts as string) ?? ""
75
+
76
+ return {
77
+ skip: false,
78
+ content: JSON.stringify(event),
79
+ meta: {
80
+ event_type: "slack",
81
+ channel_id: channelId,
82
+ user_id: userId ?? "",
83
+ mentioned: String(mentioned),
84
+ thread_ts: threadTs,
85
+ },
86
+ shouldReact: mentioned,
87
+ channel: channelId,
88
+ timestamp: (event.ts as string) ?? "",
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,72 @@
1
+ import { App, LogLevel } from "@slack/bolt"
2
+ import {
3
+ FunnelConnectorListener,
4
+ type NotifyFn,
5
+ } from "@/modules/connectors/funnel-connector-listener"
6
+ import { FunnelSlackEventProcessor } from "@/modules/connectors/funnel-slack-event-processor"
7
+ import { logger } from "@/modules/logger"
8
+ import type { SlackConnectorConfig } from "@/modules/settings/settings-schema"
9
+
10
+ type Deps = {
11
+ config: SlackConnectorConfig
12
+ }
13
+
14
+ export class FunnelSlackListener extends FunnelConnectorListener {
15
+ private readonly config: SlackConnectorConfig
16
+
17
+ constructor(deps: Deps) {
18
+ super()
19
+ this.config = deps.config
20
+ Object.freeze(this)
21
+ }
22
+
23
+ async start(notify: NotifyFn): Promise<void> {
24
+ const app = new App({
25
+ token: this.config.botToken,
26
+ appToken: this.config.appToken,
27
+ socketMode: true,
28
+ logLevel: LogLevel.ERROR,
29
+ })
30
+
31
+ const authResult = await app.client.auth.test({ token: this.config.botToken })
32
+ const processor = new FunnelSlackEventProcessor({
33
+ ownBotUserId: authResult.user_id ?? "",
34
+ ownBotId: authResult.bot_id ?? "",
35
+ })
36
+
37
+ app.use(async (args) => {
38
+ const event = (args as unknown as Record<string, unknown>).event as
39
+ | Record<string, unknown>
40
+ | undefined
41
+
42
+ if (!event) return
43
+
44
+ const result = processor.process(event)
45
+
46
+ if (result.skip) return
47
+
48
+ if (result.shouldReact) {
49
+ try {
50
+ await app.client.reactions.add({
51
+ token: this.config.botToken,
52
+ channel: result.channel,
53
+ timestamp: result.timestamp,
54
+ name: "eyes",
55
+ })
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }
60
+
61
+ await notify(result.content, result.meta)
62
+ })
63
+
64
+ app.error(async (error) => {
65
+ logger.error("Slack error", {
66
+ error: error instanceof Error ? error.message : String(error),
67
+ })
68
+ })
69
+
70
+ await app.start()
71
+ }
72
+ }
@@ -0,0 +1,13 @@
1
+ import { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
2
+ import { FunnelDiscordListener } from "@/modules/connectors/funnel-discord-listener"
3
+ import { FunnelGhListener } from "@/modules/connectors/funnel-gh-listener"
4
+ import { FunnelSlackListener } from "@/modules/connectors/funnel-slack-listener"
5
+ import type { ConnectorConfig } from "@/modules/settings/settings-schema"
6
+
7
+ export const resolveListener = (config: ConnectorConfig): FunnelConnectorListener => {
8
+ if (config.type === "slack") return new FunnelSlackListener({ config })
9
+ if (config.type === "gh") return new FunnelGhListener({ config })
10
+ if (config.type === "discord") return new FunnelDiscordListener({ config })
11
+
12
+ throw new Error(`unsupported connector type: ${(config as { type: string }).type}`)
13
+ }
@@ -0,0 +1,14 @@
1
+ export type FileStat = {
2
+ mtimeMs: number
3
+ }
4
+
5
+ export abstract class FunnelFileSystem {
6
+ abstract existsSync(path: string): boolean
7
+ abstract readFileSync(path: string): string
8
+ abstract writeFileSync(path: string, data: string): void
9
+ abstract appendFileSync(path: string, data: string): void
10
+ abstract unlink(path: string): void
11
+ abstract mkdirSync(path: string, options?: { recursive?: boolean }): void
12
+ abstract readdirSync(path: string): string[]
13
+ abstract statSync(path: string): FileStat
14
+ }