@interactive-inc/claude-funnel 0.4.1 → 0.7.1
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/README.md +100 -0
- package/lib/api.ts +54 -0
- package/lib/funnel.ts +120 -33
- package/lib/modules/channels/funnel-channels.ts +5 -0
- package/lib/modules/claude/funnel-claude.ts +21 -9
- package/lib/modules/connectors/funnel-connector-stores.ts +32 -4
- package/lib/modules/connectors/funnel-connectors.ts +6 -0
- package/lib/modules/connectors/funnel-discord-listener.ts +9 -3
- package/lib/modules/connectors/funnel-discord-store.ts +5 -1
- package/lib/modules/connectors/funnel-gh-listener.ts +14 -5
- package/lib/modules/connectors/funnel-gh-store.ts +18 -1
- package/lib/modules/connectors/funnel-schedule-listener.ts +8 -2
- package/lib/modules/connectors/funnel-schedule-store.ts +21 -4
- package/lib/modules/connectors/funnel-slack-listener.ts +8 -2
- package/lib/modules/connectors/funnel-slack-store.ts +5 -1
- package/lib/modules/connectors/migrate-legacy-connectors.ts +5 -1
- package/lib/modules/fs/funnel-file-system.ts +5 -0
- package/lib/modules/gateway/daemon.ts +10 -143
- package/lib/modules/gateway/funnel-gateway-server.ts +241 -0
- package/lib/modules/gateway/funnel-gateway.ts +49 -20
- package/lib/modules/gateway/kill-competing-slack-gateways.ts +5 -1
- package/lib/modules/id/funnel-id-generator.ts +7 -0
- package/lib/modules/id/memory-funnel-id-generator.ts +20 -0
- package/lib/modules/id/node-funnel-id-generator.ts +7 -0
- package/lib/modules/logger/funnel-logger.ts +11 -0
- package/lib/modules/logger/memory-funnel-logger.ts +28 -0
- package/lib/modules/logger/node-funnel-logger.ts +49 -0
- package/lib/modules/logger/noop-funnel-logger.ts +9 -0
- package/lib/modules/mcp/funnel-mcp.ts +5 -0
- package/lib/modules/process/funnel-process-runner.ts +5 -0
- package/lib/modules/profiles/funnel-profiles.ts +5 -0
- package/lib/modules/repos/funnel-repositories.ts +5 -0
- package/lib/modules/schedule/funnel-schedule.ts +5 -0
- package/lib/modules/time/funnel-clock.ts +15 -0
- package/lib/modules/time/memory-funnel-clock.ts +26 -0
- package/lib/modules/time/node-funnel-clock.ts +7 -0
- package/lib/routes/gateway/logs.ts +3 -1
- package/package.json +13 -2
- package/lib/modules/logger.ts +0 -26
|
@@ -2,7 +2,8 @@ import {
|
|
|
2
2
|
FunnelConnectorListener,
|
|
3
3
|
type NotifyFn,
|
|
4
4
|
} from "@/modules/connectors/funnel-connector-listener"
|
|
5
|
-
import {
|
|
5
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
6
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
6
7
|
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
7
8
|
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
8
9
|
import type { GhConnectorConfig } from "@/modules/connectors/gh-connector-schema"
|
|
@@ -18,9 +19,12 @@ type GhNotification = {
|
|
|
18
19
|
type Deps = {
|
|
19
20
|
config: GhConnectorConfig
|
|
20
21
|
process?: FunnelProcessRunner
|
|
22
|
+
logger?: FunnelLogger
|
|
23
|
+
now?: () => Date
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
const defaultProcess = new NodeFunnelProcessRunner()
|
|
27
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
24
28
|
|
|
25
29
|
const MAX_SEEN = 10000
|
|
26
30
|
const KEEP_SEEN = 5000
|
|
@@ -28,14 +32,19 @@ const KEEP_SEEN = 5000
|
|
|
28
32
|
export class FunnelGhListener extends FunnelConnectorListener {
|
|
29
33
|
private readonly config: GhConnectorConfig
|
|
30
34
|
private readonly process: FunnelProcessRunner
|
|
35
|
+
private readonly logger: FunnelLogger
|
|
36
|
+
private readonly now: () => Date
|
|
31
37
|
private readonly seen = new Map<string, string>()
|
|
32
38
|
private bootstrapped = false
|
|
33
|
-
private since
|
|
39
|
+
private since: string
|
|
34
40
|
|
|
35
41
|
constructor(deps: Deps) {
|
|
36
42
|
super()
|
|
37
43
|
this.config = deps.config
|
|
38
44
|
this.process = deps.process ?? defaultProcess
|
|
45
|
+
this.logger = deps.logger ?? defaultLogger
|
|
46
|
+
this.now = deps.now ?? (() => new Date())
|
|
47
|
+
this.since = this.now().toISOString()
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
async start(notify: NotifyFn): Promise<void> {
|
|
@@ -47,14 +56,14 @@ export class FunnelGhListener extends FunnelConnectorListener {
|
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
async pollOnce(notify: NotifyFn): Promise<void> {
|
|
50
|
-
const nextSince =
|
|
59
|
+
const nextSince = this.now().toISOString()
|
|
51
60
|
const params = new URLSearchParams({ since: this.since, all: "false" })
|
|
52
61
|
|
|
53
62
|
try {
|
|
54
63
|
const result = await this.process.run(["gh", "api", `/notifications?${params}`])
|
|
55
64
|
|
|
56
65
|
if (result.exitCode !== 0) {
|
|
57
|
-
logger.error("gh poll failed", { stderr: result.stderr })
|
|
66
|
+
this.logger.error("gh poll failed", { stderr: result.stderr })
|
|
58
67
|
return
|
|
59
68
|
}
|
|
60
69
|
|
|
@@ -94,7 +103,7 @@ export class FunnelGhListener extends FunnelConnectorListener {
|
|
|
94
103
|
this.since = nextSince
|
|
95
104
|
this.bootstrapped = true
|
|
96
105
|
} catch (error) {
|
|
97
|
-
logger.error("gh poll error", {
|
|
106
|
+
this.logger.error("gh poll error", {
|
|
98
107
|
error: error instanceof Error ? error.message : String(error),
|
|
99
108
|
})
|
|
100
109
|
}
|
|
@@ -12,10 +12,16 @@ import {
|
|
|
12
12
|
ghConnectorSchema,
|
|
13
13
|
} from "@/modules/connectors/gh-connector-schema"
|
|
14
14
|
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
15
|
+
import type { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
16
|
+
import type { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
17
|
+
import type { FunnelClock } from "@/modules/time/funnel-clock"
|
|
15
18
|
|
|
16
19
|
type Deps = {
|
|
17
20
|
fs?: FunnelFileSystem
|
|
18
21
|
dir?: string
|
|
22
|
+
process?: FunnelProcessRunner
|
|
23
|
+
logger?: FunnelLogger
|
|
24
|
+
clock?: FunnelClock
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export type GhUpdateFields = {
|
|
@@ -25,6 +31,9 @@ export type GhUpdateFields = {
|
|
|
25
31
|
export class FunnelGhStore extends FunnelCallableConnectorStore<GhConnectorConfig> {
|
|
26
32
|
readonly type = "gh" as const
|
|
27
33
|
private readonly store: FunnelJsonConnectorStore<GhConnectorConfig>
|
|
34
|
+
private readonly process?: FunnelProcessRunner
|
|
35
|
+
private readonly logger?: FunnelLogger
|
|
36
|
+
private readonly clock?: FunnelClock
|
|
28
37
|
|
|
29
38
|
constructor(deps: Deps = {}) {
|
|
30
39
|
super()
|
|
@@ -34,6 +43,9 @@ export class FunnelGhStore extends FunnelCallableConnectorStore<GhConnectorConfi
|
|
|
34
43
|
fs: deps.fs,
|
|
35
44
|
dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
|
|
36
45
|
})
|
|
46
|
+
this.process = deps.process
|
|
47
|
+
this.logger = deps.logger
|
|
48
|
+
this.clock = deps.clock
|
|
37
49
|
Object.freeze(this)
|
|
38
50
|
}
|
|
39
51
|
|
|
@@ -75,7 +87,12 @@ export class FunnelGhStore extends FunnelCallableConnectorStore<GhConnectorConfi
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
createListener(config: GhConnectorConfig): FunnelConnectorListener {
|
|
78
|
-
return new FunnelGhListener({
|
|
90
|
+
return new FunnelGhListener({
|
|
91
|
+
config,
|
|
92
|
+
process: this.process,
|
|
93
|
+
logger: this.logger,
|
|
94
|
+
now: this.clock ? () => this.clock!.now() : undefined,
|
|
95
|
+
})
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
createAdapter(_config: GhConnectorConfig): FunnelConnectorAdapter {
|
|
@@ -5,22 +5,27 @@ import {
|
|
|
5
5
|
import { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
|
|
6
6
|
import { matchCron } from "@/modules/connectors/match-cron"
|
|
7
7
|
import { ScheduleLastFiredStore } from "@/modules/connectors/schedule-last-fired-store"
|
|
8
|
-
import {
|
|
8
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
9
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
9
10
|
import type { ScheduleConnectorConfig } from "@/modules/connectors/schedule-connector-schema"
|
|
10
11
|
|
|
11
12
|
type Deps = {
|
|
12
13
|
config: ScheduleConnectorConfig
|
|
13
14
|
store: FunnelScheduleStore
|
|
14
15
|
lastFiredStore: ScheduleLastFiredStore
|
|
16
|
+
logger?: FunnelLogger
|
|
15
17
|
now?: () => Date
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
21
|
+
|
|
18
22
|
const MAX_CATCHUP_MINUTES = 60 * 24
|
|
19
23
|
|
|
20
24
|
export class FunnelScheduleListener extends FunnelConnectorListener {
|
|
21
25
|
private readonly config: ScheduleConnectorConfig
|
|
22
26
|
private readonly store: FunnelScheduleStore
|
|
23
27
|
private readonly lastFiredStore: ScheduleLastFiredStore
|
|
28
|
+
private readonly logger: FunnelLogger
|
|
24
29
|
private readonly now: () => Date
|
|
25
30
|
|
|
26
31
|
constructor(deps: Deps) {
|
|
@@ -28,6 +33,7 @@ export class FunnelScheduleListener extends FunnelConnectorListener {
|
|
|
28
33
|
this.config = deps.config
|
|
29
34
|
this.store = deps.store
|
|
30
35
|
this.lastFiredStore = deps.lastFiredStore
|
|
36
|
+
this.logger = deps.logger ?? defaultLogger
|
|
31
37
|
this.now = deps.now ?? (() => new Date())
|
|
32
38
|
Object.freeze(this)
|
|
33
39
|
}
|
|
@@ -103,7 +109,7 @@ export class FunnelScheduleListener extends FunnelConnectorListener {
|
|
|
103
109
|
try {
|
|
104
110
|
if (matchCron(cron, candidate)) return candidate
|
|
105
111
|
} catch (error) {
|
|
106
|
-
logger.error("invalid cron expression in schedule", {
|
|
112
|
+
this.logger.error("invalid cron expression in schedule", {
|
|
107
113
|
connector: this.config.name,
|
|
108
114
|
id: entryId,
|
|
109
115
|
cron,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
2
|
import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
|
|
3
3
|
import { FunnelConnectorTypeStore } from "@/modules/connectors/funnel-connector-type-store"
|
|
4
|
-
import { logger } from "@/modules/logger"
|
|
5
4
|
import { DEFAULT_FUNNEL_DIR } from "@/modules/connectors/funnel-json-connector-store"
|
|
6
5
|
import { FunnelScheduleListener } from "@/modules/connectors/funnel-schedule-listener"
|
|
7
6
|
import { ScheduleLastFiredStore } from "@/modules/connectors/schedule-last-fired-store"
|
|
@@ -12,25 +11,41 @@ import {
|
|
|
12
11
|
} from "@/modules/connectors/schedule-connector-schema"
|
|
13
12
|
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
14
13
|
import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
|
|
14
|
+
import { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
|
|
15
|
+
import { NodeFunnelIdGenerator } from "@/modules/id/node-funnel-id-generator"
|
|
16
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
17
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
18
|
+
import type { FunnelClock } from "@/modules/time/funnel-clock"
|
|
15
19
|
|
|
16
20
|
type Deps = {
|
|
17
21
|
fs?: FunnelFileSystem
|
|
18
22
|
dir?: string
|
|
23
|
+
logger?: FunnelLogger
|
|
24
|
+
idGenerator?: FunnelIdGenerator
|
|
25
|
+
clock?: FunnelClock
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
const defaultFs = new NodeFunnelFileSystem()
|
|
29
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
30
|
+
const defaultIdGenerator = new NodeFunnelIdGenerator()
|
|
22
31
|
|
|
23
32
|
export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnectorConfig> {
|
|
24
33
|
readonly type = "schedule" as const
|
|
25
34
|
private readonly fs: FunnelFileSystem
|
|
26
35
|
private readonly baseDir: string
|
|
27
36
|
private readonly dir: string
|
|
37
|
+
private readonly logger: FunnelLogger
|
|
38
|
+
private readonly idGenerator: FunnelIdGenerator
|
|
39
|
+
private readonly clock?: FunnelClock
|
|
28
40
|
|
|
29
41
|
constructor(deps: Deps = {}) {
|
|
30
42
|
super()
|
|
31
43
|
this.fs = deps.fs ?? defaultFs
|
|
32
44
|
this.baseDir = deps.dir ?? DEFAULT_FUNNEL_DIR
|
|
33
45
|
this.dir = join(this.baseDir, "connectors", "schedule")
|
|
46
|
+
this.logger = deps.logger ?? defaultLogger
|
|
47
|
+
this.idGenerator = deps.idGenerator ?? defaultIdGenerator
|
|
48
|
+
this.clock = deps.clock
|
|
34
49
|
Object.freeze(this)
|
|
35
50
|
}
|
|
36
51
|
|
|
@@ -96,7 +111,7 @@ export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnec
|
|
|
96
111
|
if (!this.has(name)) throw new Error(`connector "${name}" not found`)
|
|
97
112
|
|
|
98
113
|
const full: ScheduleEntry = {
|
|
99
|
-
id: entry.id ??
|
|
114
|
+
id: entry.id ?? this.idGenerator.generate(),
|
|
100
115
|
cron: entry.cron,
|
|
101
116
|
prompt: entry.prompt,
|
|
102
117
|
enabled: entry.enabled ?? true,
|
|
@@ -122,6 +137,8 @@ export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnec
|
|
|
122
137
|
config,
|
|
123
138
|
store: this,
|
|
124
139
|
lastFiredStore: this.createLastFiredStore(config.name),
|
|
140
|
+
logger: this.logger,
|
|
141
|
+
now: this.clock ? () => this.clock!.now() : undefined,
|
|
125
142
|
})
|
|
126
143
|
}
|
|
127
144
|
|
|
@@ -155,7 +172,7 @@ export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnec
|
|
|
155
172
|
const result = scheduleEntrySchema.safeParse(parsed)
|
|
156
173
|
|
|
157
174
|
if (!result.success) {
|
|
158
|
-
logger.warn("skipping invalid schedule entry", {
|
|
175
|
+
this.logger.warn("skipping invalid schedule entry", {
|
|
159
176
|
connector: name,
|
|
160
177
|
line: lineNumber,
|
|
161
178
|
issues: result.error.issues.map((iss) => `${iss.path.join(".")}: ${iss.message}`),
|
|
@@ -165,7 +182,7 @@ export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnec
|
|
|
165
182
|
|
|
166
183
|
entries.push(result.data)
|
|
167
184
|
} catch (error) {
|
|
168
|
-
logger.warn("skipping unparseable schedule entry", {
|
|
185
|
+
this.logger.warn("skipping unparseable schedule entry", {
|
|
169
186
|
connector: name,
|
|
170
187
|
line: lineNumber,
|
|
171
188
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -4,19 +4,25 @@ import {
|
|
|
4
4
|
type NotifyFn,
|
|
5
5
|
} from "@/modules/connectors/funnel-connector-listener"
|
|
6
6
|
import { FunnelSlackEventProcessor } from "@/modules/connectors/funnel-slack-event-processor"
|
|
7
|
-
import {
|
|
7
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
8
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
8
9
|
import type { SlackConnectorConfig } from "@/modules/connectors/slack-connector-schema"
|
|
9
10
|
|
|
10
11
|
type Deps = {
|
|
11
12
|
config: SlackConnectorConfig
|
|
13
|
+
logger?: FunnelLogger
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
17
|
+
|
|
14
18
|
export class FunnelSlackListener extends FunnelConnectorListener {
|
|
15
19
|
private readonly config: SlackConnectorConfig
|
|
20
|
+
private readonly logger: FunnelLogger
|
|
16
21
|
|
|
17
22
|
constructor(deps: Deps) {
|
|
18
23
|
super()
|
|
19
24
|
this.config = deps.config
|
|
25
|
+
this.logger = deps.logger ?? defaultLogger
|
|
20
26
|
Object.freeze(this)
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -62,7 +68,7 @@ export class FunnelSlackListener extends FunnelConnectorListener {
|
|
|
62
68
|
})
|
|
63
69
|
|
|
64
70
|
app.error(async (error) => {
|
|
65
|
-
logger.error("Slack error", {
|
|
71
|
+
this.logger.error("Slack error", {
|
|
66
72
|
error: error instanceof Error ? error.message : String(error),
|
|
67
73
|
})
|
|
68
74
|
})
|
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
slackConnectorSchema,
|
|
13
13
|
} from "@/modules/connectors/slack-connector-schema"
|
|
14
14
|
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
15
|
+
import type { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
15
16
|
|
|
16
17
|
type Deps = {
|
|
17
18
|
fs?: FunnelFileSystem
|
|
18
19
|
dir?: string
|
|
20
|
+
logger?: FunnelLogger
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export type SlackUpdateFields = {
|
|
@@ -26,6 +28,7 @@ export type SlackUpdateFields = {
|
|
|
26
28
|
export class FunnelSlackStore extends FunnelCallableConnectorStore<SlackConnectorConfig> {
|
|
27
29
|
readonly type = "slack" as const
|
|
28
30
|
private readonly store: FunnelJsonConnectorStore<SlackConnectorConfig>
|
|
31
|
+
private readonly logger?: FunnelLogger
|
|
29
32
|
|
|
30
33
|
constructor(deps: Deps = {}) {
|
|
31
34
|
super()
|
|
@@ -35,6 +38,7 @@ export class FunnelSlackStore extends FunnelCallableConnectorStore<SlackConnecto
|
|
|
35
38
|
fs: deps.fs,
|
|
36
39
|
dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
|
|
37
40
|
})
|
|
41
|
+
this.logger = deps.logger
|
|
38
42
|
Object.freeze(this)
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -77,7 +81,7 @@ export class FunnelSlackStore extends FunnelCallableConnectorStore<SlackConnecto
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
createListener(config: SlackConnectorConfig): FunnelConnectorListener {
|
|
80
|
-
return new FunnelSlackListener({ config })
|
|
84
|
+
return new FunnelSlackListener({ config, logger: this.logger })
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
createAdapter(config: SlackConnectorConfig): FunnelConnectorAdapter {
|
|
@@ -4,20 +4,24 @@ import type { ConnectorStoresBundle } from "@/modules/connectors/funnel-connecto
|
|
|
4
4
|
import { DEFAULT_FUNNEL_DIR } from "@/modules/connectors/funnel-json-connector-store"
|
|
5
5
|
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
6
6
|
import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
|
|
7
|
-
import {
|
|
7
|
+
import { FunnelLogger } from "@/modules/logger/funnel-logger"
|
|
8
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
8
9
|
|
|
9
10
|
type Props = {
|
|
10
11
|
stores: ConnectorStoresBundle
|
|
11
12
|
fs?: FunnelFileSystem
|
|
12
13
|
dir?: string
|
|
14
|
+
logger?: FunnelLogger
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
const defaultFs = new NodeFunnelFileSystem()
|
|
18
|
+
const defaultLogger = new NodeFunnelLogger()
|
|
16
19
|
|
|
17
20
|
export const migrateLegacyConnectors = (props: Props): number => {
|
|
18
21
|
const fs = props.fs ?? defaultFs
|
|
19
22
|
const base = props.dir ?? DEFAULT_FUNNEL_DIR
|
|
20
23
|
const path = join(base, "settings.json")
|
|
24
|
+
const logger = props.logger ?? defaultLogger
|
|
21
25
|
|
|
22
26
|
if (!fs.existsSync(path)) return 0
|
|
23
27
|
|
|
@@ -2,6 +2,11 @@ export type FileStat = {
|
|
|
2
2
|
mtimeMs: number
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Filesystem boundary used everywhere funnel reads or writes.
|
|
7
|
+
* Default is NodeFunnelFileSystem (real `node:fs`); MemoryFunnelFileSystem
|
|
8
|
+
* provides a sandbox for tests and embedded use.
|
|
9
|
+
*/
|
|
5
10
|
export abstract class FunnelFileSystem {
|
|
6
11
|
abstract existsSync(path: string): boolean
|
|
7
12
|
abstract readFileSync(path: string): string
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { join } from "node:path"
|
|
4
|
-
import type { ServerWebSocket } from "bun"
|
|
5
|
-
import { Hono } from "hono"
|
|
6
|
-
import { logger } from "@/modules/logger"
|
|
7
4
|
import { FunnelChannels } from "@/modules/channels/funnel-channels"
|
|
8
5
|
import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
|
|
9
6
|
import { createConnectorStores } from "@/modules/connectors/funnel-connector-stores"
|
|
10
7
|
import { migrateLegacyConnectors } from "@/modules/connectors/migrate-legacy-connectors"
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { killCompetingSlackGateways } from "@/modules/gateway/kill-competing-slack-gateways"
|
|
8
|
+
import { FunnelGatewayServer } from "@/modules/gateway/funnel-gateway-server"
|
|
9
|
+
import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
|
|
14
10
|
import { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
|
|
15
11
|
import { FUNNEL_DIR, FunnelSettingsStore } from "@/modules/settings/funnel-settings-store"
|
|
16
12
|
|
|
@@ -18,6 +14,8 @@ const PORT = Number(process.env.FUNNEL_PORT) || 9742
|
|
|
18
14
|
const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
|
|
19
15
|
const LOG_DIR = "/tmp/funnel/events"
|
|
20
16
|
|
|
17
|
+
const logger = new NodeFunnelLogger()
|
|
18
|
+
|
|
21
19
|
mkdirSync(FUNNEL_DIR, { recursive: true })
|
|
22
20
|
|
|
23
21
|
if (existsSync(PID_FILE)) {
|
|
@@ -65,143 +63,12 @@ const connectors: FunnelConnectors = new FunnelConnectors({
|
|
|
65
63
|
refUpdater: channels,
|
|
66
64
|
})
|
|
67
65
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
app.get("/health", (c) =>
|
|
73
|
-
c.json({
|
|
74
|
-
ok: true,
|
|
75
|
-
pid: process.pid,
|
|
76
|
-
clients: broadcaster.getClientCount(),
|
|
77
|
-
}),
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
app.get("/status", (c) =>
|
|
81
|
-
c.json({
|
|
82
|
-
ok: true,
|
|
83
|
-
clients: broadcaster.listChannels(),
|
|
84
|
-
}),
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
const resolveConnectors = (channelName: string): string[] => {
|
|
88
|
-
const settings = store.read()
|
|
89
|
-
const channel = settings?.channels.find((c) => c.name === channelName)
|
|
90
|
-
|
|
91
|
-
return channel?.connectors ?? []
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
type WsData = { channel: string; connectors: string[] }
|
|
95
|
-
|
|
96
|
-
Bun.serve<WsData>({
|
|
66
|
+
const server = new FunnelGatewayServer({
|
|
67
|
+
connectors,
|
|
68
|
+
settings: store,
|
|
97
69
|
port: PORT,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
|
|
102
|
-
const channelName = url.searchParams.get("channel") ?? ""
|
|
103
|
-
const connectors = channelName ? resolveConnectors(channelName) : []
|
|
104
|
-
const data: WsData = { channel: channelName, connectors }
|
|
105
|
-
|
|
106
|
-
const upgraded = server.upgrade(request, { data })
|
|
107
|
-
|
|
108
|
-
if (upgraded) return undefined
|
|
109
|
-
|
|
110
|
-
return new Response("WebSocket upgrade failed", { status: 400 })
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return app.fetch(request)
|
|
114
|
-
},
|
|
115
|
-
websocket: {
|
|
116
|
-
open(ws: ServerWebSocket<WsData>) {
|
|
117
|
-
const data = ws.data
|
|
118
|
-
|
|
119
|
-
broadcaster.addClient(ws, data)
|
|
120
|
-
|
|
121
|
-
eventLogger.log("channel connected", {
|
|
122
|
-
event_type: "system",
|
|
123
|
-
action: "channel_connect",
|
|
124
|
-
channel: data.channel,
|
|
125
|
-
connectors: data.connectors.join(","),
|
|
126
|
-
total: String(broadcaster.getClientCount()),
|
|
127
|
-
})
|
|
128
|
-
},
|
|
129
|
-
close(ws: ServerWebSocket<WsData>) {
|
|
130
|
-
broadcaster.removeClient(ws)
|
|
131
|
-
|
|
132
|
-
eventLogger.log("channel disconnected", {
|
|
133
|
-
event_type: "system",
|
|
134
|
-
action: "channel_disconnect",
|
|
135
|
-
total: String(broadcaster.getClientCount()),
|
|
136
|
-
})
|
|
137
|
-
},
|
|
138
|
-
message(_ws: ServerWebSocket<WsData>, _message: string | Buffer) {
|
|
139
|
-
// Future: channel → gateway messages
|
|
140
|
-
},
|
|
141
|
-
},
|
|
70
|
+
logDir: LOG_DIR,
|
|
71
|
+
logger,
|
|
142
72
|
})
|
|
143
73
|
|
|
144
|
-
|
|
145
|
-
event_type: "system",
|
|
146
|
-
action: "gateway_start",
|
|
147
|
-
port: String(PORT),
|
|
148
|
-
pid: String(process.pid),
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
logger.info(`funnel gateway listening`, {
|
|
152
|
-
url: `http://localhost:${PORT}`,
|
|
153
|
-
websocket: `ws://localhost:${PORT}/ws`,
|
|
154
|
-
health: `http://localhost:${PORT}/health`,
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
const notify = async (
|
|
158
|
-
connectorName: string,
|
|
159
|
-
content: string,
|
|
160
|
-
meta?: Record<string, string>,
|
|
161
|
-
): Promise<void> => {
|
|
162
|
-
const withConnector: Record<string, string> = { ...meta, connector: connectorName }
|
|
163
|
-
|
|
164
|
-
eventLogger.log(content, withConnector)
|
|
165
|
-
broadcaster.broadcast(content, withConnector)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const allConnectors = connectors.list()
|
|
169
|
-
|
|
170
|
-
// Multiple Slack Socket Mode connections sharing one App Token steal DMs/mentions
|
|
171
|
-
// from each other. Terminate other bun + gateway/bolt/slack processes first.
|
|
172
|
-
if (allConnectors.some((c) => c.type === "slack")) {
|
|
173
|
-
const killed = await killCompetingSlackGateways({ selfPid: process.pid })
|
|
174
|
-
|
|
175
|
-
if (killed.length > 0) {
|
|
176
|
-
eventLogger.log("killed competing Slack gateway processes", {
|
|
177
|
-
event_type: "system",
|
|
178
|
-
action: "kill_competing",
|
|
179
|
-
pids: killed.join(","),
|
|
180
|
-
})
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
for (const { config, listener } of connectors.createListeners()) {
|
|
185
|
-
const bind = (content: string, meta?: Record<string, string>) =>
|
|
186
|
-
notify(config.name, content, meta)
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
await listener.start(bind)
|
|
190
|
-
|
|
191
|
-
eventLogger.log(`${config.type} listener started: ${config.name}`, {
|
|
192
|
-
event_type: "system",
|
|
193
|
-
action: `${config.type}_connect`,
|
|
194
|
-
connector: config.name,
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
logger.info(`${config.type} listener started`, { connector: config.name })
|
|
198
|
-
} catch (error) {
|
|
199
|
-
logger.error(`${config.type} listener failed`, {
|
|
200
|
-
connector: config.name,
|
|
201
|
-
error: error instanceof Error ? error.message : String(error),
|
|
202
|
-
})
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
logger.info(`event logs: ${LOG_DIR}`)
|
|
207
|
-
logger.info("funnel gateway running")
|
|
74
|
+
await server.start()
|