@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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/lib/factory.ts +10 -0
- package/lib/funnel.ts +51 -0
- package/lib/index.ts +86 -0
- package/lib/modules/agents/funnel-agents.ts +105 -0
- package/lib/modules/channels/funnel-channels.ts +113 -0
- package/lib/modules/claude/funnel-claude.ts +136 -0
- package/lib/modules/connectors/funnel-connector-adapter.ts +9 -0
- package/lib/modules/connectors/funnel-connector-listener.ts +5 -0
- package/lib/modules/connectors/funnel-connectors.ts +124 -0
- package/lib/modules/connectors/funnel-discord-adapter.ts +56 -0
- package/lib/modules/connectors/funnel-discord-event-processor.ts +48 -0
- package/lib/modules/connectors/funnel-discord-listener.ts +65 -0
- package/lib/modules/connectors/funnel-gh-adapter.ts +51 -0
- package/lib/modules/connectors/funnel-gh-listener.ts +102 -0
- package/lib/modules/connectors/funnel-slack-adapter.ts +31 -0
- package/lib/modules/connectors/funnel-slack-event-processor.ts +91 -0
- package/lib/modules/connectors/funnel-slack-listener.ts +72 -0
- package/lib/modules/connectors/resolve-listener.ts +13 -0
- package/lib/modules/fs/funnel-file-system.ts +14 -0
- package/lib/modules/fs/memory-funnel-file-system.ts +85 -0
- package/lib/modules/fs/node-funnel-file-system.ts +56 -0
- package/lib/modules/gateway/daemon.ts +190 -0
- package/lib/modules/gateway/funnel-broadcaster.ts +37 -0
- package/lib/modules/gateway/funnel-event-logger.ts +59 -0
- package/lib/modules/gateway/funnel-gateway.ts +166 -0
- package/lib/modules/gateway/kill-competing-slack-gateways.ts +52 -0
- package/lib/modules/http/funnel-http-client.ts +17 -0
- package/lib/modules/http/memory-funnel-http-client.ts +40 -0
- package/lib/modules/http/node-funnel-http-client.ts +27 -0
- package/lib/modules/logger.ts +26 -0
- package/lib/modules/mcp/channel-server.ts +77 -0
- package/lib/modules/mcp/funnel-mcp.ts +107 -0
- package/lib/modules/process/funnel-process-runner.ts +28 -0
- package/lib/modules/process/memory-funnel-process-runner.ts +88 -0
- package/lib/modules/process/node-funnel-process-runner.ts +100 -0
- package/lib/modules/repos/funnel-repositories.ts +107 -0
- package/lib/modules/router/query-to-cli-args.ts +20 -0
- package/lib/modules/router/to-request.ts +122 -0
- package/lib/modules/router/validator.ts +27 -0
- package/lib/modules/settings/funnel-settings-reader.ts +6 -0
- package/lib/modules/settings/funnel-settings-store.ts +57 -0
- package/lib/modules/settings/mock-funnel-settings-reader.ts +27 -0
- package/lib/modules/settings/settings-schema.ts +67 -0
- package/lib/modules/tui/app.tsx +44 -0
- package/lib/modules/tui/tui.tsx +13 -0
- package/lib/routes/agents/add.help.ts +3 -0
- package/lib/routes/agents/add.ts +33 -0
- package/lib/routes/agents/group.help.ts +13 -0
- package/lib/routes/agents/group.ts +25 -0
- package/lib/routes/agents/launch.help.ts +3 -0
- package/lib/routes/agents/launch.ts +35 -0
- package/lib/routes/agents/remove.help.ts +3 -0
- package/lib/routes/agents/remove.ts +17 -0
- package/lib/routes/agents/rename.help.ts +5 -0
- package/lib/routes/agents/rename.ts +17 -0
- package/lib/routes/agents/routes.ts +17 -0
- package/lib/routes/agents/set.help.ts +5 -0
- package/lib/routes/agents/set.ts +32 -0
- package/lib/routes/channels/add.help.ts +3 -0
- package/lib/routes/channels/add.ts +21 -0
- package/lib/routes/channels/connectors-attach.help.ts +3 -0
- package/lib/routes/channels/connectors-attach.ts +17 -0
- package/lib/routes/channels/connectors-detach.help.ts +3 -0
- package/lib/routes/channels/connectors-detach.ts +17 -0
- package/lib/routes/channels/group.help.ts +16 -0
- package/lib/routes/channels/group.ts +22 -0
- package/lib/routes/channels/remove.help.ts +3 -0
- package/lib/routes/channels/remove.ts +17 -0
- package/lib/routes/channels/rename.help.ts +5 -0
- package/lib/routes/channels/rename.ts +17 -0
- package/lib/routes/channels/routes.ts +19 -0
- package/lib/routes/channels/show.help.ts +1 -0
- package/lib/routes/channels/show.ts +26 -0
- package/lib/routes/claude/claude.help.ts +11 -0
- package/lib/routes/claude/claude.ts +39 -0
- package/lib/routes/claude/routes.ts +4 -0
- package/lib/routes/connectors/add.help.ts +22 -0
- package/lib/routes/connectors/add.ts +55 -0
- package/lib/routes/connectors/call.help.ts +17 -0
- package/lib/routes/connectors/call.ts +43 -0
- package/lib/routes/connectors/group.help.ts +14 -0
- package/lib/routes/connectors/group.ts +18 -0
- package/lib/routes/connectors/remove.help.ts +3 -0
- package/lib/routes/connectors/remove.ts +17 -0
- package/lib/routes/connectors/rename.help.ts +5 -0
- package/lib/routes/connectors/rename.ts +17 -0
- package/lib/routes/connectors/routes.ts +19 -0
- package/lib/routes/connectors/set.help.ts +8 -0
- package/lib/routes/connectors/set.ts +30 -0
- package/lib/routes/connectors/show.help.ts +1 -0
- package/lib/routes/connectors/show.ts +32 -0
- package/lib/routes/gateway/group.help.ts +15 -0
- package/lib/routes/gateway/group.ts +28 -0
- package/lib/routes/gateway/logs.help.ts +13 -0
- package/lib/routes/gateway/logs.ts +100 -0
- package/lib/routes/gateway/restart.help.ts +10 -0
- package/lib/routes/gateway/restart.ts +35 -0
- package/lib/routes/gateway/routes.ts +18 -0
- package/lib/routes/gateway/run.help.ts +12 -0
- package/lib/routes/gateway/run.ts +35 -0
- package/lib/routes/gateway/start.help.ts +15 -0
- package/lib/routes/gateway/start.ts +32 -0
- package/lib/routes/gateway/status.help.ts +9 -0
- package/lib/routes/gateway/status.ts +28 -0
- package/lib/routes/gateway/stop.help.ts +8 -0
- package/lib/routes/gateway/stop.ts +21 -0
- package/lib/routes/repos/add.help.ts +5 -0
- package/lib/routes/repos/add.ts +19 -0
- package/lib/routes/repos/group.help.ts +11 -0
- package/lib/routes/repos/group.ts +18 -0
- package/lib/routes/repos/remove.help.ts +3 -0
- package/lib/routes/repos/remove.ts +17 -0
- package/lib/routes/repos/rename.help.ts +5 -0
- package/lib/routes/repos/rename.ts +17 -0
- package/lib/routes/repos/routes.ts +17 -0
- package/lib/routes/repos/set.help.ts +5 -0
- package/lib/routes/repos/set.ts +21 -0
- package/lib/routes/repos/show.help.ts +1 -0
- package/lib/routes/repos/show.ts +19 -0
- package/lib/routes/status/routes.ts +4 -0
- package/lib/routes/status/status.help.ts +6 -0
- package/lib/routes/status/status.ts +77 -0
- package/lib/routes.ts +36 -0
- 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
|
+
}
|