@interactive-inc/claude-funnel 0.8.0 → 0.10.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/README.md +179 -80
- package/dist/bin.js +693 -698
- package/dist/connector-adapter-CXB-q_XC.d.ts +11 -0
- package/dist/connector-adapter-D5Utumgz.js +4 -0
- package/dist/connectors/discord.d.ts +76 -0
- package/dist/connectors/discord.js +2 -0
- package/dist/connectors/gh.d.ts +38 -0
- package/dist/connectors/gh.js +2 -0
- package/dist/connectors/schedule.d.ts +53 -0
- package/dist/connectors/schedule.js +2 -0
- package/dist/connectors/slack.d.ts +62 -0
- package/dist/connectors/slack.js +2 -0
- package/dist/discord-connector-schema-Dww2I4zH.d.ts +14 -0
- package/dist/discord-connector-schema-ygf5Df-2.js +173 -0
- package/dist/file-system-Co60LrmR.d.ts +74 -0
- package/dist/gateway/daemon.js +243 -221
- package/dist/gh-connector-schema-2ml29MBC.js +218 -0
- package/dist/gh-connector-schema-BZFAS-p-.d.ts +45 -0
- package/dist/index.d.ts +3888 -0
- package/dist/index.js +6296 -0
- package/dist/logger-CTlXs7z4.d.ts +33 -0
- package/dist/node-logger-DQz_BGOD.js +61 -0
- package/dist/schedule-connector-schema-CkuIQ0JQ.js +325 -0
- package/dist/slack-connector-schema-Cd22WiHB.js +153 -0
- package/dist/slack-connector-schema-D7zAHN8k.d.ts +15 -0
- package/lib/bin.ts +1 -76
- package/lib/cli/index.ts +85 -0
- package/lib/cli/router/to-request.ts +1 -0
- package/lib/cli/routes/channels.$channel.publish.ts +52 -0
- package/lib/cli/routes/claude.ts +1 -0
- package/lib/cli/routes/index.ts +35 -18
- package/lib/cli/routes/profiles.add.$profile.ts +5 -2
- package/lib/cli/routes/profiles.set.$profile.ts +10 -11
- package/lib/connectors/discord.ts +4 -0
- package/lib/connectors/gh.ts +3 -0
- package/lib/connectors/schedule.ts +4 -0
- package/lib/connectors/slack.ts +4 -0
- package/lib/engine/claude/claude.ts +6 -0
- package/lib/engine/mcp/channel-server.ts +34 -115
- package/lib/engine/mcp/channel-subscriber.ts +82 -0
- package/lib/engine/mcp/read-channel-connectors.ts +34 -0
- package/lib/engine/mcp/read-gateway-token.ts +16 -0
- package/lib/engine/mcp/usage-hint-for-type.ts +15 -0
- package/lib/engine/settings/settings-schema.ts +2 -0
- package/lib/funnel.ts +162 -55
- package/lib/gateway/broadcaster.ts +1 -1
- package/lib/gateway/channel-publisher.ts +67 -0
- package/lib/gateway/gateway-server.ts +28 -16
- package/lib/gateway/publish-schema.ts +27 -0
- package/lib/gateway/routes/channels.publish.ts +44 -0
- package/lib/gateway/routes/index.ts +2 -0
- package/lib/gateway/routes/route-deps.ts +8 -0
- package/lib/index.ts +17 -0
- package/package.json +41 -25
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const usageHintForType = (type: string): string => {
|
|
2
|
+
if (type === "slack") {
|
|
3
|
+
return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (type === "discord") {
|
|
7
|
+
return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (type === "gh") {
|
|
11
|
+
return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return "Generic adapter call."
|
|
15
|
+
}
|
|
@@ -30,6 +30,8 @@ export const profileConfigSchema = z.object({
|
|
|
30
30
|
path: z.string(),
|
|
31
31
|
subAgent: z.string(),
|
|
32
32
|
channelId: z.string(),
|
|
33
|
+
/** Forwards `--brief` to claude on launch (enables the SendUserMessage tool). */
|
|
34
|
+
brief: z.boolean().optional(),
|
|
33
35
|
})
|
|
34
36
|
|
|
35
37
|
export type ProfileConfig = z.infer<typeof profileConfigSchema>
|
package/lib/funnel.ts
CHANGED
|
@@ -2,22 +2,36 @@ import { join } from "node:path"
|
|
|
2
2
|
import { FunnelConnectorFactory } from "@/connectors/connector-factory"
|
|
3
3
|
import { FunnelChannels } from "@/engine/channels/channels"
|
|
4
4
|
import { FunnelClaude } from "@/engine/claude/claude"
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import { FunnelFileSystem } from "@/engine/fs/file-system"
|
|
6
|
+
import { MemoryFunnelFileSystem } from "@/engine/fs/memory-file-system"
|
|
7
|
+
import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
|
|
8
|
+
import { FunnelIdGenerator } from "@/engine/id/id-generator"
|
|
9
|
+
import { MemoryFunnelIdGenerator } from "@/engine/id/memory-id-generator"
|
|
10
|
+
import { NodeFunnelIdGenerator } from "@/engine/id/node-id-generator"
|
|
7
11
|
import { FunnelLogger } from "@/engine/logger/logger"
|
|
12
|
+
import { MemoryFunnelLogger } from "@/engine/logger/memory-logger"
|
|
8
13
|
import { NodeFunnelLogger } from "@/engine/logger/node-logger"
|
|
9
14
|
import { FunnelMcp } from "@/engine/mcp/mcp"
|
|
10
15
|
import { FunnelProcessRunner } from "@/engine/process/process-runner"
|
|
16
|
+
import { MemoryFunnelProcessRunner } from "@/engine/process/memory-process-runner"
|
|
11
17
|
import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
|
|
12
18
|
import { FunnelProfiles } from "@/engine/profiles/profiles"
|
|
19
|
+
import { MockFunnelSettingsReader } from "@/engine/settings/mock-settings-reader"
|
|
13
20
|
import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
|
|
14
21
|
import { FUNNEL_DIR, FunnelSettingsStore } from "@/engine/settings/settings-store"
|
|
15
|
-
import
|
|
22
|
+
import { FunnelClock } from "@/engine/time/clock"
|
|
23
|
+
import { MemoryFunnelClock } from "@/engine/time/memory-clock"
|
|
24
|
+
import { NodeFunnelClock } from "@/engine/time/node-clock"
|
|
25
|
+
import { FunnelChannelPublisher } from "@/gateway/channel-publisher"
|
|
16
26
|
import { FunnelGateway } from "@/gateway/gateway"
|
|
17
27
|
import { FunnelGatewayServer } from "@/gateway/gateway-server"
|
|
18
28
|
import { FunnelGatewayToken } from "@/gateway/gateway-token"
|
|
19
29
|
import { FunnelListenersClient } from "@/gateway/listeners-client"
|
|
20
30
|
|
|
31
|
+
const DEFAULT_TMP_DIR = "/tmp/funnel"
|
|
32
|
+
const SANDBOX_DIR = "/sandbox/.funnel"
|
|
33
|
+
const SANDBOX_TMP_DIR = "/sandbox/tmp"
|
|
34
|
+
|
|
21
35
|
type Props = {
|
|
22
36
|
/** Settings persistence (channels with nested connectors / profiles). Defaults to a FunnelSettingsStore rooted at `dir`. */
|
|
23
37
|
store?: FunnelSettingsReader
|
|
@@ -57,89 +71,180 @@ type Props = {
|
|
|
57
71
|
* ```
|
|
58
72
|
*/
|
|
59
73
|
export class Funnel {
|
|
74
|
+
private readonly cache = new Map<string, unknown>()
|
|
75
|
+
|
|
60
76
|
constructor(private readonly props: Props = {}) {
|
|
61
77
|
Object.freeze(this)
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Sandboxed Funnel wired with in-memory implementations for every IO boundary.
|
|
82
|
+
* Touches no real disk, processes, wall-clock time, or UUIDs — safe for tests
|
|
83
|
+
* and ad-hoc experiments. Override individual fields by passing them in `props`.
|
|
84
|
+
*/
|
|
85
|
+
static inMemory(props: Props = {}): Funnel {
|
|
86
|
+
return new Funnel({
|
|
87
|
+
store: props.store ?? new MockFunnelSettingsReader(),
|
|
88
|
+
fs: props.fs ?? new MemoryFunnelFileSystem(),
|
|
89
|
+
process: props.process ?? new MemoryFunnelProcessRunner(),
|
|
90
|
+
logger: props.logger ?? new MemoryFunnelLogger(),
|
|
91
|
+
clock: props.clock ?? new MemoryFunnelClock(),
|
|
92
|
+
idGenerator: props.idGenerator ?? new MemoryFunnelIdGenerator(),
|
|
93
|
+
dir: props.dir ?? SANDBOX_DIR,
|
|
94
|
+
tmpDir: props.tmpDir ?? SANDBOX_TMP_DIR,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private memo<T>(key: string, build: () => T): T {
|
|
99
|
+
if (this.cache.has(key)) return this.cache.get(key) as T
|
|
100
|
+
|
|
101
|
+
const value = build()
|
|
102
|
+
this.cache.set(key, value)
|
|
103
|
+
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
|
|
108
|
+
get paths(): { dir: string; tmpDir: string; settings: string } {
|
|
109
|
+
const dir = this.props.dir ?? FUNNEL_DIR
|
|
110
|
+
const tmpDir = this.props.tmpDir ?? DEFAULT_TMP_DIR
|
|
111
|
+
|
|
112
|
+
return { dir, tmpDir, settings: join(dir, "settings.json") }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Filesystem boundary. Defaults to NodeFunnelFileSystem. */
|
|
116
|
+
get fs(): FunnelFileSystem {
|
|
117
|
+
return this.memo("fs", () => this.props.fs ?? new NodeFunnelFileSystem())
|
|
73
118
|
}
|
|
74
119
|
|
|
75
120
|
/** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
|
|
76
121
|
get process(): FunnelProcessRunner {
|
|
77
|
-
return this.props.process ?? new NodeFunnelProcessRunner()
|
|
122
|
+
return this.memo("process", () => this.props.process ?? new NodeFunnelProcessRunner())
|
|
78
123
|
}
|
|
79
124
|
|
|
80
125
|
/** Logger boundary. Defaults to NodeFunnelLogger. */
|
|
81
126
|
get logger(): FunnelLogger {
|
|
82
|
-
return this.props.logger ?? new NodeFunnelLogger()
|
|
127
|
+
return this.memo("logger", () => this.props.logger ?? new NodeFunnelLogger())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Clock boundary. Defaults to NodeFunnelClock. */
|
|
131
|
+
get clock(): FunnelClock {
|
|
132
|
+
return this.memo("clock", () => this.props.clock ?? new NodeFunnelClock())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
|
|
136
|
+
get idGenerator(): FunnelIdGenerator {
|
|
137
|
+
return this.memo("idGenerator", () => this.props.idGenerator ?? new NodeFunnelIdGenerator())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
|
|
141
|
+
get store(): FunnelSettingsReader {
|
|
142
|
+
return this.memo(
|
|
143
|
+
"store",
|
|
144
|
+
() =>
|
|
145
|
+
this.props.store ??
|
|
146
|
+
new FunnelSettingsStore({
|
|
147
|
+
path: this.paths.settings,
|
|
148
|
+
fs: this.fs,
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
83
151
|
}
|
|
84
152
|
|
|
85
153
|
/** Pure factory that constructs per-type listeners and adapters from connector configs. */
|
|
86
154
|
get factory(): FunnelConnectorFactory {
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
155
|
+
return this.memo(
|
|
156
|
+
"factory",
|
|
157
|
+
() =>
|
|
158
|
+
new FunnelConnectorFactory({
|
|
159
|
+
fs: this.fs,
|
|
160
|
+
process: this.process,
|
|
161
|
+
logger: this.logger,
|
|
162
|
+
dir: this.paths.dir,
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
93
165
|
}
|
|
94
166
|
|
|
95
167
|
/** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
|
|
96
168
|
get channels(): FunnelChannels {
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
169
|
+
return this.memo(
|
|
170
|
+
"channels",
|
|
171
|
+
() =>
|
|
172
|
+
new FunnelChannels({
|
|
173
|
+
store: this.store,
|
|
174
|
+
factory: this.factory,
|
|
175
|
+
profileChecker: this.profiles,
|
|
176
|
+
clock: this.clock,
|
|
177
|
+
idGenerator: this.idGenerator,
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
104
180
|
}
|
|
105
181
|
|
|
106
182
|
/** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
|
|
107
183
|
get profiles(): FunnelProfiles {
|
|
108
|
-
return new FunnelProfiles({ store: this.store })
|
|
184
|
+
return this.memo("profiles", () => new FunnelProfiles({ store: this.store }))
|
|
109
185
|
}
|
|
110
186
|
|
|
111
187
|
/** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
|
|
112
188
|
get mcp(): FunnelMcp {
|
|
113
|
-
return new FunnelMcp({ fs: this.
|
|
189
|
+
return this.memo("mcp", () => new FunnelMcp({ fs: this.fs }))
|
|
114
190
|
}
|
|
115
191
|
|
|
116
192
|
/** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
|
|
117
193
|
get claude(): FunnelClaude {
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
194
|
+
return this.memo(
|
|
195
|
+
"claude",
|
|
196
|
+
() =>
|
|
197
|
+
new FunnelClaude({
|
|
198
|
+
channels: this.channels,
|
|
199
|
+
mcp: this.mcp,
|
|
200
|
+
gateway: this.gateway,
|
|
201
|
+
fs: this.fs,
|
|
202
|
+
process: this.process,
|
|
203
|
+
logger: this.logger,
|
|
204
|
+
dir: this.paths.dir,
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
127
207
|
}
|
|
128
208
|
|
|
129
209
|
/** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
|
|
130
210
|
get gateway(): FunnelGateway {
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
211
|
+
return this.memo(
|
|
212
|
+
"gateway",
|
|
213
|
+
() =>
|
|
214
|
+
new FunnelGateway({
|
|
215
|
+
fs: this.fs,
|
|
216
|
+
process: this.process,
|
|
217
|
+
clock: this.clock,
|
|
218
|
+
dir: this.paths.dir,
|
|
219
|
+
tmpDir: this.paths.tmpDir,
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
138
222
|
}
|
|
139
223
|
|
|
140
224
|
/** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
|
|
141
225
|
get gatewayToken(): FunnelGatewayToken {
|
|
142
|
-
return
|
|
226
|
+
return this.memo(
|
|
227
|
+
"gatewayToken",
|
|
228
|
+
() => new FunnelGatewayToken({ fs: this.fs, dir: this.paths.dir }),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* HTTP client for `POST /channels/:channel/publish` on the running gateway
|
|
234
|
+
* daemon. Use it to push arbitrary content into a channel from outside any
|
|
235
|
+
* connector. Returns `{ state: "offline" }` if the daemon isn't up.
|
|
236
|
+
*/
|
|
237
|
+
get publisher(): FunnelChannelPublisher {
|
|
238
|
+
return this.memo("publisher", () => {
|
|
239
|
+
const gateway = this.gateway
|
|
240
|
+
const token = this.gatewayToken
|
|
241
|
+
|
|
242
|
+
return new FunnelChannelPublisher({
|
|
243
|
+
port: gateway.getPort(),
|
|
244
|
+
isDaemonRunning: () => gateway.isRunning(),
|
|
245
|
+
getToken: () => token.read(),
|
|
246
|
+
})
|
|
247
|
+
})
|
|
143
248
|
}
|
|
144
249
|
|
|
145
250
|
/**
|
|
@@ -148,13 +253,15 @@ export class Funnel {
|
|
|
148
253
|
* paths stay write-only without parsing strings.
|
|
149
254
|
*/
|
|
150
255
|
get listeners(): FunnelListenersClient {
|
|
151
|
-
|
|
152
|
-
|
|
256
|
+
return this.memo("listeners", () => {
|
|
257
|
+
const gateway = this.gateway
|
|
258
|
+
const token = this.gatewayToken
|
|
153
259
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
260
|
+
return new FunnelListenersClient({
|
|
261
|
+
port: gateway.getPort(),
|
|
262
|
+
isDaemonRunning: () => gateway.isRunning(),
|
|
263
|
+
getToken: () => token.read(),
|
|
264
|
+
})
|
|
158
265
|
})
|
|
159
266
|
}
|
|
160
267
|
|
|
@@ -177,9 +284,9 @@ export class Funnel {
|
|
|
177
284
|
settings: this.store,
|
|
178
285
|
port: options.port,
|
|
179
286
|
logDir: options.logDir,
|
|
180
|
-
process: this.
|
|
181
|
-
clock: this.
|
|
182
|
-
logger: this.
|
|
287
|
+
process: this.process,
|
|
288
|
+
clock: this.clock,
|
|
289
|
+
logger: this.logger,
|
|
183
290
|
killCompetingSlack: options.killCompetingSlack,
|
|
184
291
|
token: options.token ?? this.gatewayToken.ensure(),
|
|
185
292
|
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
publishResponseSchema,
|
|
3
|
+
type PublishRequest,
|
|
4
|
+
type PublishResult,
|
|
5
|
+
} from "@/gateway/publish-schema"
|
|
6
|
+
|
|
7
|
+
type Deps = {
|
|
8
|
+
port: number
|
|
9
|
+
isDaemonRunning: () => boolean
|
|
10
|
+
/** Returns the daemon's gateway token, or null if unavailable. Sent as `Authorization: Bearer`. */
|
|
11
|
+
getToken?: () => string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const OFFLINE: PublishResult = { state: "offline" }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HTTP client for `POST /channels/:channel/publish` on a running gateway
|
|
18
|
+
* daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
|
|
19
|
+
* can branch without exceptions, mirroring `FunnelListenersClient`.
|
|
20
|
+
*/
|
|
21
|
+
export class FunnelChannelPublisher {
|
|
22
|
+
private readonly port: number
|
|
23
|
+
private readonly isDaemonRunning: () => boolean
|
|
24
|
+
private readonly getToken: () => string | null
|
|
25
|
+
|
|
26
|
+
constructor(deps: Deps) {
|
|
27
|
+
this.port = deps.port
|
|
28
|
+
this.isDaemonRunning = deps.isDaemonRunning
|
|
29
|
+
this.getToken = deps.getToken ?? (() => null)
|
|
30
|
+
Object.freeze(this)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async publish(channelName: string, request: PublishRequest): Promise<PublishResult> {
|
|
34
|
+
if (!this.isDaemonRunning()) return OFFLINE
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const url = `http://localhost:${this.port}/channels/${encodeURIComponent(channelName)}/publish`
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { ...this.authHeaders(), "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify(request),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const text = await res.text()
|
|
46
|
+
|
|
47
|
+
return { state: "error", reason: text || `HTTP ${res.status}` }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parsed = publishResponseSchema.safeParse(await res.json())
|
|
51
|
+
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
return { state: "error", reason: "malformed daemon response" }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { state: "ok", offset: parsed.data.offset }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return { state: "error", reason: error instanceof Error ? error.message : String(error) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private authHeaders(): Record<string, string> {
|
|
63
|
+
const token = this.getToken()
|
|
64
|
+
|
|
65
|
+
return token ? { authorization: `Bearer ${token}` } : {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs"
|
|
2
2
|
import { join } from "node:path"
|
|
3
|
-
import type
|
|
3
|
+
import { type Server, type ServerWebSocket } from "bun"
|
|
4
4
|
import type { Hono } from "hono"
|
|
5
5
|
import type { FunnelChannels } from "@/engine/channels/channels"
|
|
6
6
|
import { constantTimeEqual, requireBearerToken } from "@/gateway/auth-middleware"
|
|
@@ -104,8 +104,9 @@ export class FunnelGatewayServer {
|
|
|
104
104
|
this.supervisor = new FunnelListenerSupervisor({
|
|
105
105
|
channels: this.channels,
|
|
106
106
|
logger: this.logger,
|
|
107
|
-
notify: (channelName, connectorName, content, meta) =>
|
|
108
|
-
this.
|
|
107
|
+
notify: async (channelName, connectorName, content, meta) => {
|
|
108
|
+
this.emit({ channel: channelName, connector: connectorName, content, meta })
|
|
109
|
+
},
|
|
109
110
|
now: this.nowMs,
|
|
110
111
|
})
|
|
111
112
|
}
|
|
@@ -278,6 +279,7 @@ export class FunnelGatewayServer {
|
|
|
278
279
|
supervisor: this.supervisor,
|
|
279
280
|
channels: this.channels,
|
|
280
281
|
uptimeMs: () => (this.startedAt ? this.nowMs() - this.startedAt : 0),
|
|
282
|
+
emit: (input) => this.emit(input),
|
|
281
283
|
})
|
|
282
284
|
|
|
283
285
|
return next()
|
|
@@ -371,32 +373,42 @@ export class FunnelGatewayServer {
|
|
|
371
373
|
this.logger.info("funnel gateway running")
|
|
372
374
|
}
|
|
373
375
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
376
|
+
/**
|
|
377
|
+
* Broadcast `content` to subscribers of `channel`, persisting the event in
|
|
378
|
+
* the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
|
|
379
|
+
* when they resolve. Used by both the connector-listener path (via the
|
|
380
|
+
* supervisor's `notify` callback) and the public `/channels/:channel/publish`
|
|
381
|
+
* route. Returns the assigned event offset.
|
|
382
|
+
*/
|
|
383
|
+
emit(input: {
|
|
384
|
+
channel: string
|
|
385
|
+
connector?: string
|
|
386
|
+
content: string
|
|
387
|
+
meta?: Record<string, string>
|
|
388
|
+
}): { offset: number } {
|
|
389
|
+
const channelId = this.lookupChannelId(input.channel)
|
|
390
|
+
const connectorId =
|
|
391
|
+
channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null
|
|
382
392
|
const enriched: Record<string, string> = {
|
|
383
|
-
...meta,
|
|
384
|
-
channel:
|
|
385
|
-
connector: connectorName,
|
|
393
|
+
...input.meta,
|
|
394
|
+
channel: input.channel,
|
|
386
395
|
}
|
|
387
396
|
|
|
397
|
+
if (input.connector) enriched.connector = input.connector
|
|
388
398
|
if (channelId) enriched.channelId = channelId
|
|
389
399
|
if (connectorId) enriched.connectorId = connectorId
|
|
390
400
|
|
|
391
|
-
const event = this.broadcaster.broadcast(content, enriched)
|
|
401
|
+
const event = this.broadcaster.broadcast(input.content, enriched)
|
|
392
402
|
|
|
393
403
|
this.eventStore.record({
|
|
394
|
-
content,
|
|
404
|
+
content: input.content,
|
|
395
405
|
channelId: channelId ?? null,
|
|
396
406
|
connectorId: connectorId ?? null,
|
|
397
407
|
meta: enriched,
|
|
398
408
|
offset: event.offset,
|
|
399
409
|
})
|
|
410
|
+
|
|
411
|
+
return { offset: event.offset }
|
|
400
412
|
}
|
|
401
413
|
|
|
402
414
|
private lookupChannelId(channelName: string): string | null {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared schema for `POST /channels/:channel/publish` — used by both the
|
|
5
|
+
* gateway route handler (input validation) and the CLI / programmable client
|
|
6
|
+
* (request shape). The route resolves `channel` from the path; this body
|
|
7
|
+
* covers everything else.
|
|
8
|
+
*/
|
|
9
|
+
export const publishRequestSchema = z.object({
|
|
10
|
+
content: z.string().min(1),
|
|
11
|
+
meta: z.record(z.string(), z.string()).optional(),
|
|
12
|
+
connector: z.string().min(1).optional(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type PublishRequest = z.infer<typeof publishRequestSchema>
|
|
16
|
+
|
|
17
|
+
export const publishResponseSchema = z.object({
|
|
18
|
+
ok: z.literal(true),
|
|
19
|
+
offset: z.number().int().nonnegative(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export type PublishResponse = z.infer<typeof publishResponseSchema>
|
|
23
|
+
|
|
24
|
+
export type PublishResult =
|
|
25
|
+
| { state: "ok"; offset: number }
|
|
26
|
+
| { state: "offline" }
|
|
27
|
+
| { state: "error"; reason: string }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { factory } from "@/gateway/factory"
|
|
4
|
+
import { publishRequestSchema } from "@/gateway/publish-schema"
|
|
5
|
+
import type { PublishResponse } from "@/gateway/publish-schema"
|
|
6
|
+
import { zParam } from "@/gateway/routes/validator"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /channels/:channel/publish
|
|
10
|
+
*
|
|
11
|
+
* Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
|
|
12
|
+
* path: events go through `broadcaster.broadcast` + `eventStore.record`, so
|
|
13
|
+
* subscribers see them exactly as if a listener had produced them.
|
|
14
|
+
*
|
|
15
|
+
* Body validation is Zod-shared with the client (`publishRequestSchema`); the
|
|
16
|
+
* response (`publishResponseSchema`) carries the assigned offset so callers can
|
|
17
|
+
* correlate with the persistent event store.
|
|
18
|
+
*/
|
|
19
|
+
export const channelsPublishHandler = factory.createHandlers(
|
|
20
|
+
zParam(z.object({ channel: z.string().min(1) })),
|
|
21
|
+
zValidator("json", publishRequestSchema, (result, c) => {
|
|
22
|
+
if (result.success) return
|
|
23
|
+
|
|
24
|
+
const issue = result.error.issues[0]
|
|
25
|
+
const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body"
|
|
26
|
+
|
|
27
|
+
return c.json({ ok: false, reason }, 400)
|
|
28
|
+
}),
|
|
29
|
+
(c) => {
|
|
30
|
+
const param = c.req.valid("param")
|
|
31
|
+
const body = c.req.valid("json")
|
|
32
|
+
|
|
33
|
+
const event = c.var.deps.emit({
|
|
34
|
+
channel: param.channel,
|
|
35
|
+
connector: body.connector,
|
|
36
|
+
content: body.content,
|
|
37
|
+
meta: body.meta,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const response: PublishResponse = { ok: true, offset: event.offset }
|
|
41
|
+
|
|
42
|
+
return c.json(response)
|
|
43
|
+
},
|
|
44
|
+
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { factory } from "@/gateway/factory"
|
|
2
2
|
import { channelsConnectorsCallHandler } from "@/gateway/routes/channels.connectors.call"
|
|
3
|
+
import { channelsPublishHandler } from "@/gateway/routes/channels.publish"
|
|
3
4
|
import { healthHandler } from "@/gateway/routes/health"
|
|
4
5
|
import { listenersListHandler } from "@/gateway/routes/listeners.list"
|
|
5
6
|
import { listenersRestartHandler } from "@/gateway/routes/listeners.restart"
|
|
@@ -22,3 +23,4 @@ export const gatewayRoutes = factory
|
|
|
22
23
|
.delete("/listeners/:channel/:connector", ...listenersStopHandler)
|
|
23
24
|
.post("/listeners/:channel/:connector/restart", ...listenersRestartHandler)
|
|
24
25
|
.post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler)
|
|
26
|
+
.post("/channels/:channel/publish", ...channelsPublishHandler)
|
|
@@ -2,10 +2,18 @@ import type { FunnelChannels } from "@/engine/channels/channels"
|
|
|
2
2
|
import type { FunnelBroadcaster } from "@/gateway/broadcaster"
|
|
3
3
|
import type { FunnelListenerSupervisor } from "@/gateway/listener-supervisor"
|
|
4
4
|
|
|
5
|
+
export type GatewayEmitInput = {
|
|
6
|
+
channel: string
|
|
7
|
+
connector?: string
|
|
8
|
+
content: string
|
|
9
|
+
meta?: Record<string, string>
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export type GatewayRouteDeps = {
|
|
6
13
|
selfPid: number
|
|
7
14
|
broadcaster: FunnelBroadcaster
|
|
8
15
|
supervisor: FunnelListenerSupervisor
|
|
9
16
|
channels: FunnelChannels
|
|
10
17
|
uptimeMs: () => number
|
|
18
|
+
emit: (input: GatewayEmitInput) => { offset: number }
|
|
11
19
|
}
|
package/lib/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "@/funnel"
|
|
|
8
8
|
export * from "@/engine/channels/channels"
|
|
9
9
|
export * from "@/engine/claude/claude"
|
|
10
10
|
export * from "@/engine/mcp/mcp"
|
|
11
|
+
export * from "@/engine/mcp/channel-server"
|
|
11
12
|
export * from "@/engine/profiles/profiles"
|
|
12
13
|
export * from "@/engine/settings/settings-reader"
|
|
13
14
|
export * from "@/engine/settings/settings-store"
|
|
@@ -39,12 +40,28 @@ export * from "@/engine/id/memory-id-generator"
|
|
|
39
40
|
// Connectors
|
|
40
41
|
export * from "@/connectors/connector-factory"
|
|
41
42
|
export * from "@/connectors/connector-config-schema"
|
|
43
|
+
export * from "@/connectors/connector-listener"
|
|
44
|
+
export * from "@/connectors/discord-connector-schema"
|
|
45
|
+
export * from "@/connectors/gh-connector-schema"
|
|
42
46
|
export * from "@/connectors/schedule-connector-schema"
|
|
47
|
+
export * from "@/connectors/slack-connector-schema"
|
|
43
48
|
|
|
44
49
|
// Gateway
|
|
45
50
|
export * from "@/gateway/gateway"
|
|
46
51
|
export * from "@/gateway/gateway-server"
|
|
52
|
+
export * from "@/gateway/gateway-token"
|
|
47
53
|
export * from "@/gateway/broadcaster"
|
|
54
|
+
export * from "@/gateway/channel-publisher"
|
|
48
55
|
export * from "@/gateway/funnel-event-store"
|
|
49
56
|
export * from "@/gateway/listener-supervisor"
|
|
50
57
|
export * from "@/gateway/listeners-client"
|
|
58
|
+
export * from "@/gateway/publish-schema"
|
|
59
|
+
|
|
60
|
+
// CLI — embeddable Hono app + argv translator
|
|
61
|
+
export * from "@/cli/factory"
|
|
62
|
+
export * from "@/cli/router/to-request"
|
|
63
|
+
export * from "@/cli/router/query-to-cli-args"
|
|
64
|
+
export { app as cliApp, createCliApp } from "@/cli/routes"
|
|
65
|
+
|
|
66
|
+
// TUI — launcher (consumers can spawn the OpenTUI dashboard with their own Funnel)
|
|
67
|
+
export * from "@/tui/tui"
|