@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,85 @@
|
|
|
1
|
+
import { type FileStat, FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
dirs?: string[]
|
|
5
|
+
files?: Record<string, string>
|
|
6
|
+
mtimes?: Record<string, number>
|
|
7
|
+
now?: () => number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MemoryFunnelFileSystem extends FunnelFileSystem {
|
|
11
|
+
private readonly dirs: Set<string>
|
|
12
|
+
private readonly files: Map<string, string>
|
|
13
|
+
private readonly mtimes: Map<string, number>
|
|
14
|
+
private readonly now: () => number
|
|
15
|
+
|
|
16
|
+
constructor(props: Props = {}) {
|
|
17
|
+
super()
|
|
18
|
+
this.dirs = new Set(props.dirs ?? [])
|
|
19
|
+
this.files = new Map(Object.entries(props.files ?? {}))
|
|
20
|
+
this.mtimes = new Map(Object.entries(props.mtimes ?? {}))
|
|
21
|
+
this.now = props.now ?? (() => Date.now())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
existsSync(path: string): boolean {
|
|
25
|
+
return this.dirs.has(path) || this.files.has(path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
readFileSync(path: string): string {
|
|
29
|
+
return this.files.get(path) ?? ""
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
writeFileSync(path: string, data: string): void {
|
|
33
|
+
this.files.set(path, data)
|
|
34
|
+
this.touch(path)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
appendFileSync(path: string, data: string): void {
|
|
38
|
+
const prev = this.files.get(path) ?? ""
|
|
39
|
+
this.files.set(path, prev + data)
|
|
40
|
+
this.touch(path)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
unlink(path: string): void {
|
|
44
|
+
this.files.delete(path)
|
|
45
|
+
this.mtimes.delete(path)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
mkdirSync(path: string): void {
|
|
49
|
+
this.dirs.add(path)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
readdirSync(path: string): string[] {
|
|
53
|
+
const prefix = path.endsWith("/") ? path : `${path}/`
|
|
54
|
+
const names: string[] = []
|
|
55
|
+
|
|
56
|
+
for (const file of this.files.keys()) {
|
|
57
|
+
if (!file.startsWith(prefix)) continue
|
|
58
|
+
|
|
59
|
+
const rest = file.slice(prefix.length)
|
|
60
|
+
|
|
61
|
+
if (!rest.includes("/")) names.push(rest)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return names
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
statSync(path: string): FileStat {
|
|
68
|
+
const mtimeMs = this.mtimes.get(path)
|
|
69
|
+
|
|
70
|
+
if (mtimeMs === undefined) {
|
|
71
|
+
throw new Error(`not found: ${path}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { mtimeMs }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setMtime(path: string, mtimeMs: number): void {
|
|
78
|
+
this.mtimes.set(path, mtimeMs)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private touch(path: string): void {
|
|
82
|
+
if (!this.mtimes.has(path)) this.mtimes.set(path, this.now())
|
|
83
|
+
else this.mtimes.set(path, this.now())
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs"
|
|
11
|
+
import { type FileStat, FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
12
|
+
|
|
13
|
+
export class NodeFunnelFileSystem extends FunnelFileSystem {
|
|
14
|
+
constructor() {
|
|
15
|
+
super()
|
|
16
|
+
Object.freeze(this)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
existsSync(path: string): boolean {
|
|
20
|
+
return existsSync(path)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
readFileSync(path: string): string {
|
|
24
|
+
return readFileSync(path, "utf-8")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
writeFileSync(path: string, data: string): void {
|
|
28
|
+
writeFileSync(path, data)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
appendFileSync(path: string, data: string): void {
|
|
32
|
+
appendFileSync(path, data)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
unlink(path: string): void {
|
|
36
|
+
try {
|
|
37
|
+
unlinkSync(path)
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
mkdirSync(path: string, options?: { recursive?: boolean }): void {
|
|
44
|
+
mkdirSync(path, { recursive: options?.recursive ?? false })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
readdirSync(path: string): string[] {
|
|
48
|
+
return readdirSync(path)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
statSync(path: string): FileStat {
|
|
52
|
+
const stat = statSync(path)
|
|
53
|
+
|
|
54
|
+
return { mtimeMs: stat.mtimeMs }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import type { ServerWebSocket } from "bun"
|
|
5
|
+
import { Hono } from "hono"
|
|
6
|
+
import { logger } from "@/modules/logger"
|
|
7
|
+
import { resolveListener } from "@/modules/connectors/resolve-listener"
|
|
8
|
+
import { FunnelBroadcaster } from "@/modules/gateway/funnel-broadcaster"
|
|
9
|
+
import { FunnelEventLogger } from "@/modules/gateway/funnel-event-logger"
|
|
10
|
+
import { killCompetingSlackGateways } from "@/modules/gateway/kill-competing-slack-gateways"
|
|
11
|
+
import { FUNNEL_DIR, FunnelSettingsStore } from "@/modules/settings/funnel-settings-store"
|
|
12
|
+
|
|
13
|
+
const PORT = Number(process.env.FUNNEL_PORT) || 9742
|
|
14
|
+
const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
|
|
15
|
+
const LOG_DIR = "/tmp/funnel/events"
|
|
16
|
+
|
|
17
|
+
mkdirSync(FUNNEL_DIR, { recursive: true })
|
|
18
|
+
|
|
19
|
+
if (existsSync(PID_FILE)) {
|
|
20
|
+
const existing = Number(readFileSync(PID_FILE, "utf-8").trim())
|
|
21
|
+
|
|
22
|
+
if (existing > 0) {
|
|
23
|
+
const check = Bun.spawnSync(["ps", "-p", String(existing), "-o", "state="], {
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
if (check.exitCode === 0 && check.stdout.toString().trim()) {
|
|
29
|
+
logger.error(`funnel gateway already running`, { pid: existing })
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
writeFileSync(PID_FILE, String(process.pid))
|
|
36
|
+
|
|
37
|
+
process.on("exit", () => {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(PID_FILE)
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
process.on("SIGINT", () => process.exit(130))
|
|
45
|
+
process.on("SIGTERM", () => process.exit(143))
|
|
46
|
+
|
|
47
|
+
const store = new FunnelSettingsStore()
|
|
48
|
+
|
|
49
|
+
const eventLogger = new FunnelEventLogger({ logDir: LOG_DIR })
|
|
50
|
+
const broadcaster = new FunnelBroadcaster()
|
|
51
|
+
const app = new Hono()
|
|
52
|
+
|
|
53
|
+
app.get("/health", (c) =>
|
|
54
|
+
c.json({
|
|
55
|
+
ok: true,
|
|
56
|
+
pid: process.pid,
|
|
57
|
+
clients: broadcaster.getClientCount(),
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
app.get("/status", (c) =>
|
|
62
|
+
c.json({
|
|
63
|
+
ok: true,
|
|
64
|
+
clients: broadcaster.listChannels(),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const resolveConnectors = (channelName: string): string[] => {
|
|
69
|
+
const settings = store.read()
|
|
70
|
+
const channel = settings?.channels.find((c) => c.name === channelName)
|
|
71
|
+
|
|
72
|
+
return channel?.connectors ?? []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type WsData = { channel: string; connectors: string[] }
|
|
76
|
+
|
|
77
|
+
Bun.serve<WsData>({
|
|
78
|
+
port: PORT,
|
|
79
|
+
fetch(request, server) {
|
|
80
|
+
const url = new URL(request.url)
|
|
81
|
+
|
|
82
|
+
if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
|
|
83
|
+
const channelName = url.searchParams.get("channel") ?? ""
|
|
84
|
+
const connectors = channelName ? resolveConnectors(channelName) : []
|
|
85
|
+
const data: WsData = { channel: channelName, connectors }
|
|
86
|
+
|
|
87
|
+
const upgraded = server.upgrade(request, { data })
|
|
88
|
+
|
|
89
|
+
if (upgraded) return undefined
|
|
90
|
+
|
|
91
|
+
return new Response("WebSocket upgrade failed", { status: 400 })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return app.fetch(request)
|
|
95
|
+
},
|
|
96
|
+
websocket: {
|
|
97
|
+
open(ws: ServerWebSocket<WsData>) {
|
|
98
|
+
const data = ws.data
|
|
99
|
+
|
|
100
|
+
broadcaster.addClient(ws, data)
|
|
101
|
+
|
|
102
|
+
eventLogger.log("channel connected", {
|
|
103
|
+
event_type: "system",
|
|
104
|
+
action: "channel_connect",
|
|
105
|
+
channel: data.channel,
|
|
106
|
+
connectors: data.connectors.join(","),
|
|
107
|
+
total: String(broadcaster.getClientCount()),
|
|
108
|
+
})
|
|
109
|
+
},
|
|
110
|
+
close(ws: ServerWebSocket<WsData>) {
|
|
111
|
+
broadcaster.removeClient(ws)
|
|
112
|
+
|
|
113
|
+
eventLogger.log("channel disconnected", {
|
|
114
|
+
event_type: "system",
|
|
115
|
+
action: "channel_disconnect",
|
|
116
|
+
total: String(broadcaster.getClientCount()),
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
message(_ws: ServerWebSocket<WsData>, _message: string | Buffer) {
|
|
120
|
+
// Future: channel → gateway messages
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
eventLogger.log("gateway started", {
|
|
126
|
+
event_type: "system",
|
|
127
|
+
action: "gateway_start",
|
|
128
|
+
port: String(PORT),
|
|
129
|
+
pid: String(process.pid),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
logger.info(`funnel gateway listening`, {
|
|
133
|
+
url: `http://localhost:${PORT}`,
|
|
134
|
+
websocket: `ws://localhost:${PORT}/ws`,
|
|
135
|
+
health: `http://localhost:${PORT}/health`,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const notify = async (
|
|
139
|
+
connectorName: string,
|
|
140
|
+
content: string,
|
|
141
|
+
meta?: Record<string, string>,
|
|
142
|
+
): Promise<void> => {
|
|
143
|
+
const withConnector: Record<string, string> = { ...meta, connector: connectorName }
|
|
144
|
+
|
|
145
|
+
eventLogger.log(content, withConnector)
|
|
146
|
+
broadcaster.broadcast(content, withConnector)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const settings = store.read()
|
|
150
|
+
|
|
151
|
+
// Multiple Slack Socket Mode connections sharing one App Token steal DMs/mentions
|
|
152
|
+
// from each other. Terminate other bun + gateway/bolt/slack processes first.
|
|
153
|
+
if (settings.connectors.some((c) => c.type === "slack")) {
|
|
154
|
+
const killed = await killCompetingSlackGateways({ selfPid: process.pid })
|
|
155
|
+
|
|
156
|
+
if (killed.length > 0) {
|
|
157
|
+
eventLogger.log("killed competing Slack gateway processes", {
|
|
158
|
+
event_type: "system",
|
|
159
|
+
action: "kill_competing",
|
|
160
|
+
pids: killed.join(","),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const connector of settings.connectors) {
|
|
166
|
+
const bind = (content: string, meta?: Record<string, string>) =>
|
|
167
|
+
notify(connector.name, content, meta)
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const listener = resolveListener(connector)
|
|
171
|
+
|
|
172
|
+
await listener.start(bind)
|
|
173
|
+
|
|
174
|
+
eventLogger.log(`${connector.type} listener started: ${connector.name}`, {
|
|
175
|
+
event_type: "system",
|
|
176
|
+
action: `${connector.type}_connect`,
|
|
177
|
+
connector: connector.name,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
logger.info(`${connector.type} listener started`, { connector: connector.name })
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.error(`${connector.type} listener failed`, {
|
|
183
|
+
connector: connector.name,
|
|
184
|
+
error: error instanceof Error ? error.message : String(error),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
logger.info(`event logs: ${LOG_DIR}`)
|
|
190
|
+
logger.info("funnel gateway running")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun"
|
|
2
|
+
|
|
3
|
+
type ClientData = {
|
|
4
|
+
channel: string
|
|
5
|
+
connectors: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class FunnelBroadcaster {
|
|
9
|
+
private readonly clients: Map<ServerWebSocket<unknown>, ClientData> = new Map()
|
|
10
|
+
|
|
11
|
+
addClient(ws: ServerWebSocket<unknown>, data: ClientData): void {
|
|
12
|
+
this.clients.set(ws, data)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
removeClient(ws: ServerWebSocket<unknown>): void {
|
|
16
|
+
this.clients.delete(ws)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getClientCount(): number {
|
|
20
|
+
return this.clients.size
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
listChannels(): { channel: string; connectors: string[] }[] {
|
|
24
|
+
return [...this.clients.values()].map((d) => ({ ...d }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
broadcast(content: string, meta?: Record<string, string>): void {
|
|
28
|
+
const payload = JSON.stringify({ content, meta })
|
|
29
|
+
const connector = meta?.connector
|
|
30
|
+
|
|
31
|
+
for (const [ws, data] of this.clients) {
|
|
32
|
+
if (connector && !data.connectors.includes(connector)) continue
|
|
33
|
+
|
|
34
|
+
ws.send(payload)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
3
|
+
import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
|
|
4
|
+
|
|
5
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
|
6
|
+
|
|
7
|
+
type Deps = {
|
|
8
|
+
logDir: string
|
|
9
|
+
fs?: FunnelFileSystem
|
|
10
|
+
now?: () => number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const defaultFs = new NodeFunnelFileSystem()
|
|
14
|
+
|
|
15
|
+
export class FunnelEventLogger {
|
|
16
|
+
private readonly logDir: string
|
|
17
|
+
private readonly fs: FunnelFileSystem
|
|
18
|
+
private readonly now: () => number
|
|
19
|
+
|
|
20
|
+
constructor(deps: Deps) {
|
|
21
|
+
this.logDir = deps.logDir
|
|
22
|
+
this.fs = deps.fs ?? defaultFs
|
|
23
|
+
this.now = deps.now ?? (() => Date.now())
|
|
24
|
+
this.fs.mkdirSync(this.logDir, { recursive: true })
|
|
25
|
+
this.rotate()
|
|
26
|
+
Object.freeze(this)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
log(content: string, meta?: Record<string, string>): void {
|
|
30
|
+
const entry = {
|
|
31
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
32
|
+
eventType: meta?.event_type ?? "unknown",
|
|
33
|
+
content: content.length > 2000 ? `${content.slice(0, 2000)}...` : content,
|
|
34
|
+
meta,
|
|
35
|
+
}
|
|
36
|
+
const dateStr = new Date(this.now()).toISOString().slice(0, 10)
|
|
37
|
+
const logFile = join(this.logDir, `${dateStr}.jsonl`)
|
|
38
|
+
|
|
39
|
+
this.fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private rotate(): void {
|
|
43
|
+
const now = this.now()
|
|
44
|
+
|
|
45
|
+
for (const name of this.fs.readdirSync(this.logDir)) {
|
|
46
|
+
if (!name.endsWith(".jsonl")) continue
|
|
47
|
+
|
|
48
|
+
const path = join(this.logDir, name)
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const stat = this.fs.statSync(path)
|
|
52
|
+
|
|
53
|
+
if (now - stat.mtimeMs > MAX_AGE_MS) this.fs.unlink(path)
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { join, resolve } from "node:path"
|
|
2
|
+
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
3
|
+
import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
|
|
4
|
+
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
5
|
+
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
6
|
+
import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PORT = 9742
|
|
9
|
+
const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
|
|
10
|
+
const LOG_DIR = "/tmp/funnel/events"
|
|
11
|
+
const GATEWAY_LOG = "/tmp/funnel/gateway.log"
|
|
12
|
+
const TMP_DIR = "/tmp/funnel"
|
|
13
|
+
|
|
14
|
+
type Deps = {
|
|
15
|
+
process?: FunnelProcessRunner
|
|
16
|
+
fs?: FunnelFileSystem
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaultProcess = new NodeFunnelProcessRunner()
|
|
20
|
+
const defaultFs = new NodeFunnelFileSystem()
|
|
21
|
+
|
|
22
|
+
export class FunnelGateway {
|
|
23
|
+
private readonly process: FunnelProcessRunner
|
|
24
|
+
private readonly fs: FunnelFileSystem
|
|
25
|
+
|
|
26
|
+
constructor(deps: Deps = {}) {
|
|
27
|
+
this.process = deps.process ?? defaultProcess
|
|
28
|
+
this.fs = deps.fs ?? defaultFs
|
|
29
|
+
Object.freeze(this)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
isRunning(): boolean {
|
|
33
|
+
const pid = this.readPid()
|
|
34
|
+
|
|
35
|
+
if (!pid) return false
|
|
36
|
+
|
|
37
|
+
return this.isProcessAlive(pid)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getStatus(): { running: boolean; pid: number | null; port: number } {
|
|
41
|
+
const pid = this.readPid()
|
|
42
|
+
const running = pid !== null && this.isProcessAlive(pid)
|
|
43
|
+
|
|
44
|
+
return { running, pid: running ? pid : null, port: DEFAULT_PORT }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async start(options: { caffeinate?: boolean } = {}): Promise<boolean> {
|
|
48
|
+
if (this.isRunning()) return true
|
|
49
|
+
|
|
50
|
+
this.fs.mkdirSync(TMP_DIR, { recursive: true })
|
|
51
|
+
|
|
52
|
+
const gatewayScript = resolve(import.meta.dir, "./daemon.ts")
|
|
53
|
+
const command = this.buildStartCommand(gatewayScript, options)
|
|
54
|
+
|
|
55
|
+
this.process.detach(["bash", "-c", command])
|
|
56
|
+
|
|
57
|
+
await Bun.sleep(800)
|
|
58
|
+
|
|
59
|
+
return this.isRunning()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
buildStartCommand(gatewayScript: string, options: { caffeinate?: boolean } = {}): string {
|
|
63
|
+
const useCaffeinate = options.caffeinate !== false && globalThis.process.platform === "darwin"
|
|
64
|
+
const prefix = useCaffeinate ? "caffeinate -i " : ""
|
|
65
|
+
|
|
66
|
+
return `nohup ${prefix}bun ${gatewayScript} >> ${GATEWAY_LOG} 2>&1 &`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async stop(): Promise<boolean> {
|
|
70
|
+
const pid = this.readPid()
|
|
71
|
+
|
|
72
|
+
if (!pid) return true
|
|
73
|
+
|
|
74
|
+
if (!this.isProcessAlive(pid)) {
|
|
75
|
+
this.removePid()
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
globalThis.process.kill(pid, "SIGTERM")
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const deadline = Date.now() + 2000
|
|
86
|
+
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
if (!this.isProcessAlive(pid)) {
|
|
89
|
+
this.removePid()
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await Bun.sleep(100)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
globalThis.process.kill(pid, "SIGKILL")
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await Bun.sleep(200)
|
|
103
|
+
this.removePid()
|
|
104
|
+
|
|
105
|
+
return !this.isProcessAlive(pid)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async restart(
|
|
109
|
+
options: { onlyIfRunning?: boolean; caffeinate?: boolean } = {},
|
|
110
|
+
): Promise<{ ok: boolean; wasRunning: boolean; stopped: boolean; started: boolean }> {
|
|
111
|
+
const wasRunning = this.isRunning()
|
|
112
|
+
|
|
113
|
+
if (options.onlyIfRunning && !wasRunning) {
|
|
114
|
+
return { ok: true, wasRunning: false, stopped: false, started: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stopped = wasRunning ? await this.stop() : true
|
|
118
|
+
|
|
119
|
+
if (!stopped) {
|
|
120
|
+
return { ok: false, wasRunning, stopped: false, started: false }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const started = await this.start({ caffeinate: options.caffeinate })
|
|
124
|
+
|
|
125
|
+
return { ok: started, wasRunning, stopped, started }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getLogDir(): string {
|
|
129
|
+
return LOG_DIR
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getGatewayLog(): string {
|
|
133
|
+
return GATEWAY_LOG
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private readPid(): number | null {
|
|
137
|
+
if (!this.fs.existsSync(PID_FILE)) return null
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const content = this.fs.readFileSync(PID_FILE).trim()
|
|
141
|
+
const pid = Number(content)
|
|
142
|
+
|
|
143
|
+
if (!pid || pid <= 0) return null
|
|
144
|
+
|
|
145
|
+
return pid
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private removePid(): void {
|
|
152
|
+
this.fs.unlink(PID_FILE)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private isProcessAlive(pid: number): boolean {
|
|
156
|
+
const result = this.process.runSync(["ps", "-p", String(pid), "-o", "state="])
|
|
157
|
+
|
|
158
|
+
if (result.exitCode !== 0) return false
|
|
159
|
+
|
|
160
|
+
const state = result.stdout.trim()
|
|
161
|
+
|
|
162
|
+
if (!state) return false
|
|
163
|
+
|
|
164
|
+
return !state.startsWith("Z")
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { logger } from "@/modules/logger"
|
|
2
|
+
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
3
|
+
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
selfPid: number
|
|
7
|
+
process?: FunnelProcessRunner
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const defaultProcess = new NodeFunnelProcessRunner()
|
|
11
|
+
|
|
12
|
+
const isBun = (args: string): boolean => {
|
|
13
|
+
return args.includes("bun ") || /\/bun(\s|$)/.test(args)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const looksLikeSlackGateway = (args: string): boolean => {
|
|
17
|
+
return /(gateway|bolt|slack)/i.test(args)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const killCompetingSlackGateways = async (props: Props): Promise<number[]> => {
|
|
21
|
+
const runner = props.process ?? defaultProcess
|
|
22
|
+
const result = await runner.run(["ps", "-e", "-o", "pid=,args="])
|
|
23
|
+
|
|
24
|
+
if (result.exitCode !== 0) return []
|
|
25
|
+
|
|
26
|
+
const killed: number[] = []
|
|
27
|
+
|
|
28
|
+
for (const raw of result.stdout.split("\n")) {
|
|
29
|
+
const line = raw.trim()
|
|
30
|
+
|
|
31
|
+
if (!line) continue
|
|
32
|
+
|
|
33
|
+
const match = /^(\d+)\s+(.+)$/.exec(line)
|
|
34
|
+
|
|
35
|
+
if (!match) continue
|
|
36
|
+
|
|
37
|
+
const pid = Number(match[1])
|
|
38
|
+
const args = match[2]!
|
|
39
|
+
|
|
40
|
+
if (!Number.isInteger(pid) || pid <= 0) continue
|
|
41
|
+
if (pid === props.selfPid) continue
|
|
42
|
+
if (!isBun(args)) continue
|
|
43
|
+
if (!looksLikeSlackGateway(args)) continue
|
|
44
|
+
|
|
45
|
+
runner.kill(pid, "SIGTERM")
|
|
46
|
+
killed.push(pid)
|
|
47
|
+
|
|
48
|
+
logger.info("killed competing Slack gateway process", { pid, args: args.slice(0, 160) })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return killed
|
|
52
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type HttpRequest = {
|
|
2
|
+
method: string
|
|
3
|
+
url: string
|
|
4
|
+
headers?: Record<string, string>
|
|
5
|
+
body?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type HttpResponse = {
|
|
9
|
+
status: number
|
|
10
|
+
ok: boolean
|
|
11
|
+
text(): Promise<string>
|
|
12
|
+
json(): Promise<unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export abstract class FunnelHttpClient {
|
|
16
|
+
abstract fetch(request: HttpRequest): Promise<HttpResponse>
|
|
17
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FunnelHttpClient,
|
|
3
|
+
type HttpRequest,
|
|
4
|
+
type HttpResponse,
|
|
5
|
+
} from "@/modules/http/funnel-http-client"
|
|
6
|
+
|
|
7
|
+
export type MemoryHttpResponse = {
|
|
8
|
+
status?: number
|
|
9
|
+
body?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MemoryHttpHandler = (
|
|
13
|
+
request: HttpRequest,
|
|
14
|
+
) => MemoryHttpResponse | Promise<MemoryHttpResponse>
|
|
15
|
+
|
|
16
|
+
export class MemoryFunnelHttpClient extends FunnelHttpClient {
|
|
17
|
+
readonly calls: HttpRequest[] = []
|
|
18
|
+
private handler: MemoryHttpHandler = () => ({ status: 200, body: "" })
|
|
19
|
+
|
|
20
|
+
on(handler: MemoryHttpHandler): this {
|
|
21
|
+
this.handler = handler
|
|
22
|
+
|
|
23
|
+
return this
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async fetch(request: HttpRequest): Promise<HttpResponse> {
|
|
27
|
+
this.calls.push(request)
|
|
28
|
+
|
|
29
|
+
const response = await this.handler(request)
|
|
30
|
+
const status = response.status ?? 200
|
|
31
|
+
const body = response.body ?? ""
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
status,
|
|
35
|
+
ok: status >= 200 && status < 300,
|
|
36
|
+
text: async () => body,
|
|
37
|
+
json: async () => JSON.parse(body),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|