@interactive-inc/claude-funnel 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +82 -26
  2. package/lib/funnel.ts +49 -5
  3. package/lib/index.ts +8 -2
  4. package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
  5. package/lib/modules/channels/funnel-channels.ts +50 -8
  6. package/lib/modules/claude/funnel-claude.ts +79 -1
  7. package/lib/modules/connectors/connector-config-schema.ts +16 -0
  8. package/lib/modules/connectors/connector-existence-checker.ts +3 -0
  9. package/lib/modules/connectors/discord-connector-schema.ts +9 -0
  10. package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
  11. package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
  12. package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
  13. package/lib/modules/connectors/funnel-connectors.ts +98 -77
  14. package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
  15. package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
  16. package/lib/modules/connectors/funnel-discord-store.ts +84 -0
  17. package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
  18. package/lib/modules/connectors/funnel-gh-store.ts +84 -0
  19. package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
  20. package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
  21. package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
  22. package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
  23. package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
  24. package/lib/modules/connectors/funnel-slack-store.ts +86 -0
  25. package/lib/modules/connectors/gh-connector-schema.ts +9 -0
  26. package/lib/modules/connectors/match-cron.ts +72 -0
  27. package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
  28. package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
  29. package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
  30. package/lib/modules/connectors/slack-connector-schema.ts +10 -0
  31. package/lib/modules/gateway/daemon.ts +30 -13
  32. package/lib/modules/mcp/channel-server.ts +1 -2
  33. package/lib/modules/profiles/funnel-profiles.ts +123 -0
  34. package/lib/modules/profiles/profile-channel-checker.ts +3 -0
  35. package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
  36. package/lib/modules/repos/funnel-repositories.ts +4 -4
  37. package/lib/modules/router/to-request.ts +2 -5
  38. package/lib/modules/schedule/funnel-schedule.ts +34 -0
  39. package/lib/modules/settings/funnel-settings-store.ts +1 -2
  40. package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -2
  41. package/lib/modules/settings/settings-schema.ts +3 -37
  42. package/lib/routes/claude/claude.help.ts +9 -4
  43. package/lib/routes/claude/claude.ts +44 -7
  44. package/lib/routes/connectors/add.help.ts +10 -4
  45. package/lib/routes/connectors/add.ts +10 -1
  46. package/lib/routes/connectors/routes.ts +6 -2
  47. package/lib/routes/connectors/schedules-add.help.ts +11 -0
  48. package/lib/routes/connectors/schedules-add.ts +33 -0
  49. package/lib/routes/connectors/schedules-group.help.ts +1 -0
  50. package/lib/routes/connectors/schedules-group.ts +38 -0
  51. package/lib/routes/connectors/schedules-remove.help.ts +3 -0
  52. package/lib/routes/connectors/schedules-remove.ts +17 -0
  53. package/lib/routes/connectors/set.ts +47 -5
  54. package/lib/routes/connectors/show.ts +9 -0
  55. package/lib/routes/profiles/add.help.ts +3 -0
  56. package/lib/routes/{agents → profiles}/add.ts +4 -4
  57. package/lib/routes/profiles/group.help.ts +16 -0
  58. package/lib/routes/profiles/group.ts +25 -0
  59. package/lib/routes/profiles/launch.help.ts +4 -0
  60. package/lib/routes/{agents → profiles}/launch.ts +9 -8
  61. package/lib/routes/profiles/remove.help.ts +3 -0
  62. package/lib/routes/{agents → profiles}/remove.ts +4 -4
  63. package/lib/routes/profiles/rename.help.ts +5 -0
  64. package/lib/routes/{agents → profiles}/rename.ts +4 -4
  65. package/lib/routes/profiles/routes.ts +18 -0
  66. package/lib/routes/profiles/set.help.ts +5 -0
  67. package/lib/routes/{agents → profiles}/set.ts +4 -4
  68. package/lib/routes/repos/add.help.ts +3 -2
  69. package/lib/routes/repos/add.ts +3 -2
  70. package/lib/routes/repos/group.help.ts +1 -1
  71. package/lib/routes/request/discord-help.ts +9 -0
  72. package/lib/routes/request/discord.help.ts +19 -0
  73. package/lib/routes/request/discord.ts +65 -0
  74. package/lib/routes/request/group.help.ts +15 -0
  75. package/lib/routes/request/group.ts +9 -0
  76. package/lib/routes/request/routes.ts +14 -0
  77. package/lib/routes/request/slack-help.ts +9 -0
  78. package/lib/routes/request/slack.help.ts +19 -0
  79. package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
  80. package/lib/routes/status/status.help.ts +1 -1
  81. package/lib/routes/status/status.ts +7 -7
  82. package/lib/routes/update/routes.ts +4 -0
  83. package/lib/routes/update/update.help.ts +5 -0
  84. package/lib/routes/update/update.ts +21 -0
  85. package/lib/routes.ts +6 -2
  86. package/package.json +1 -1
  87. package/lib/modules/agents/funnel-agents.ts +0 -105
  88. package/lib/modules/connectors/resolve-listener.ts +0 -13
  89. package/lib/routes/agents/add.help.ts +0 -3
  90. package/lib/routes/agents/group.help.ts +0 -13
  91. package/lib/routes/agents/group.ts +0 -25
  92. package/lib/routes/agents/launch.help.ts +0 -3
  93. package/lib/routes/agents/remove.help.ts +0 -3
  94. package/lib/routes/agents/rename.help.ts +0 -5
  95. package/lib/routes/agents/routes.ts +0 -17
  96. package/lib/routes/agents/set.help.ts +0 -5
  97. package/lib/routes/connectors/call.help.ts +0 -17
@@ -0,0 +1,178 @@
1
+ import { join } from "node:path"
2
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
3
+ import { FunnelConnectorTypeStore } from "@/modules/connectors/funnel-connector-type-store"
4
+ import { logger } from "@/modules/logger"
5
+ import { DEFAULT_FUNNEL_DIR } from "@/modules/connectors/funnel-json-connector-store"
6
+ import { FunnelScheduleListener } from "@/modules/connectors/funnel-schedule-listener"
7
+ import { ScheduleLastFiredStore } from "@/modules/connectors/schedule-last-fired-store"
8
+ import {
9
+ type ScheduleConnectorConfig,
10
+ type ScheduleEntry,
11
+ scheduleEntrySchema,
12
+ } from "@/modules/connectors/schedule-connector-schema"
13
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
14
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
15
+
16
+ type Deps = {
17
+ fs?: FunnelFileSystem
18
+ dir?: string
19
+ }
20
+
21
+ const defaultFs = new NodeFunnelFileSystem()
22
+
23
+ export class FunnelScheduleStore extends FunnelConnectorTypeStore<ScheduleConnectorConfig> {
24
+ readonly type = "schedule" as const
25
+ private readonly fs: FunnelFileSystem
26
+ private readonly baseDir: string
27
+ private readonly dir: string
28
+
29
+ constructor(deps: Deps = {}) {
30
+ super()
31
+ this.fs = deps.fs ?? defaultFs
32
+ this.baseDir = deps.dir ?? DEFAULT_FUNNEL_DIR
33
+ this.dir = join(this.baseDir, "connectors", "schedule")
34
+ Object.freeze(this)
35
+ }
36
+
37
+ list(): ScheduleConnectorConfig[] {
38
+ if (!this.fs.existsSync(this.dir)) return []
39
+
40
+ const files = this.fs.readdirSync(this.dir).filter((f) => f.endsWith(".jsonl"))
41
+ const configs: ScheduleConnectorConfig[] = []
42
+
43
+ for (const file of files) {
44
+ const name = file.slice(0, -6)
45
+ const config = this.get(name)
46
+
47
+ if (config) configs.push(config)
48
+ }
49
+
50
+ return configs
51
+ }
52
+
53
+ get(name: string): ScheduleConnectorConfig | null {
54
+ const path = this.pathFor(name)
55
+
56
+ if (!this.fs.existsSync(path)) return null
57
+
58
+ return { type: "schedule", name, entries: this.readEntries(name) }
59
+ }
60
+
61
+ has(name: string): boolean {
62
+ return this.fs.existsSync(this.pathFor(name))
63
+ }
64
+
65
+ add(config: ScheduleConnectorConfig): void {
66
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
67
+
68
+ this.fs.mkdirSync(this.dir, { recursive: true })
69
+ const lines = config.entries.map((e) => JSON.stringify(e)).join("\n")
70
+ this.fs.writeFileSync(this.pathFor(config.name), lines ? `${lines}\n` : "")
71
+ }
72
+
73
+ remove(name: string): void {
74
+ if (!this.has(name)) throw new Error(`connector "${name}" not found`)
75
+
76
+ this.fs.unlink(this.pathFor(name))
77
+ this.fs.unlink(this.statePathFor(name))
78
+ }
79
+
80
+ rename(oldName: string, newName: string): void {
81
+ if (!this.has(oldName)) throw new Error(`connector "${oldName}" not found`)
82
+ if (this.has(newName)) throw new Error(`connector "${newName}" already exists`)
83
+
84
+ const content = this.fs.readFileSync(this.pathFor(oldName))
85
+ this.fs.writeFileSync(this.pathFor(newName), content)
86
+ this.fs.unlink(this.pathFor(oldName))
87
+
88
+ if (this.fs.existsSync(this.statePathFor(oldName))) {
89
+ const state = this.fs.readFileSync(this.statePathFor(oldName))
90
+ this.fs.writeFileSync(this.statePathFor(newName), state)
91
+ this.fs.unlink(this.statePathFor(oldName))
92
+ }
93
+ }
94
+
95
+ addEntry(name: string, entry: Omit<ScheduleEntry, "id"> & { id?: string }): ScheduleEntry {
96
+ if (!this.has(name)) throw new Error(`connector "${name}" not found`)
97
+
98
+ const full: ScheduleEntry = {
99
+ id: entry.id ?? crypto.randomUUID(),
100
+ cron: entry.cron,
101
+ prompt: entry.prompt,
102
+ enabled: entry.enabled ?? true,
103
+ }
104
+
105
+ this.fs.appendFileSync(this.pathFor(name), `${JSON.stringify(full)}\n`)
106
+
107
+ return full
108
+ }
109
+
110
+ removeEntry(name: string, id: string): void {
111
+ const entries = this.readEntries(name)
112
+ const next = entries.filter((e) => e.id !== id)
113
+
114
+ if (next.length === entries.length) throw new Error(`schedule entry "${id}" not found`)
115
+
116
+ const content = next.map((e) => JSON.stringify(e)).join("\n")
117
+ this.fs.writeFileSync(this.pathFor(name), content ? `${content}\n` : "")
118
+ }
119
+
120
+ createListener(config: ScheduleConnectorConfig): FunnelConnectorListener {
121
+ return new FunnelScheduleListener({
122
+ config,
123
+ store: this,
124
+ lastFiredStore: this.createLastFiredStore(config.name),
125
+ })
126
+ }
127
+
128
+ private createLastFiredStore(name: string): ScheduleLastFiredStore {
129
+ return new ScheduleLastFiredStore({ connector: name, fs: this.fs, dir: this.baseDir })
130
+ }
131
+
132
+ private pathFor(name: string): string {
133
+ return join(this.dir, `${name}.jsonl`)
134
+ }
135
+
136
+ private statePathFor(name: string): string {
137
+ return join(this.dir, `${name}.state.json`)
138
+ }
139
+
140
+ private readEntries(name: string): ScheduleEntry[] {
141
+ const path = this.pathFor(name)
142
+
143
+ if (!this.fs.existsSync(path)) return []
144
+
145
+ const content = this.fs.readFileSync(path)
146
+ const lines = content.split("\n").filter((l) => l.trim().length > 0)
147
+ const entries: ScheduleEntry[] = []
148
+
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const line = lines[i]!
151
+ const lineNumber = i + 1
152
+
153
+ try {
154
+ const parsed = JSON.parse(line)
155
+ const result = scheduleEntrySchema.safeParse(parsed)
156
+
157
+ if (!result.success) {
158
+ logger.warn("skipping invalid schedule entry", {
159
+ connector: name,
160
+ line: lineNumber,
161
+ issues: result.error.issues.map((iss) => `${iss.path.join(".")}: ${iss.message}`),
162
+ })
163
+ continue
164
+ }
165
+
166
+ entries.push(result.data)
167
+ } catch (error) {
168
+ logger.warn("skipping unparseable schedule entry", {
169
+ connector: name,
170
+ line: lineNumber,
171
+ error: error instanceof Error ? error.message : String(error),
172
+ })
173
+ }
174
+ }
175
+
176
+ return entries
177
+ }
178
+ }
@@ -3,7 +3,7 @@ import {
3
3
  FunnelConnectorAdapter,
4
4
  type CallInput,
5
5
  } from "@/modules/connectors/funnel-connector-adapter"
6
- import type { SlackConnectorConfig } from "@/modules/settings/settings-schema"
6
+ import type { SlackConnectorConfig } from "@/modules/connectors/slack-connector-schema"
7
7
 
8
8
  export type SlackWebClientLike = {
9
9
  apiCall: (method: string, options: Record<string, unknown>) => Promise<unknown>
@@ -5,7 +5,7 @@ import {
5
5
  } from "@/modules/connectors/funnel-connector-listener"
6
6
  import { FunnelSlackEventProcessor } from "@/modules/connectors/funnel-slack-event-processor"
7
7
  import { logger } from "@/modules/logger"
8
- import type { SlackConnectorConfig } from "@/modules/settings/settings-schema"
8
+ import type { SlackConnectorConfig } from "@/modules/connectors/slack-connector-schema"
9
9
 
10
10
  type Deps = {
11
11
  config: SlackConnectorConfig
@@ -0,0 +1,86 @@
1
+ import { FunnelCallableConnectorStore } from "@/modules/connectors/funnel-callable-connector-store"
2
+ import type { FunnelConnectorAdapter } from "@/modules/connectors/funnel-connector-adapter"
3
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
4
+ import {
5
+ DEFAULT_FUNNEL_DIR,
6
+ FunnelJsonConnectorStore,
7
+ } from "@/modules/connectors/funnel-json-connector-store"
8
+ import { FunnelSlackAdapter } from "@/modules/connectors/funnel-slack-adapter"
9
+ import { FunnelSlackListener } from "@/modules/connectors/funnel-slack-listener"
10
+ import {
11
+ type SlackConnectorConfig,
12
+ slackConnectorSchema,
13
+ } from "@/modules/connectors/slack-connector-schema"
14
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
15
+
16
+ type Deps = {
17
+ fs?: FunnelFileSystem
18
+ dir?: string
19
+ }
20
+
21
+ export type SlackUpdateFields = {
22
+ botToken?: string
23
+ appToken?: string
24
+ }
25
+
26
+ export class FunnelSlackStore extends FunnelCallableConnectorStore<SlackConnectorConfig> {
27
+ readonly type = "slack" as const
28
+ private readonly store: FunnelJsonConnectorStore<SlackConnectorConfig>
29
+
30
+ constructor(deps: Deps = {}) {
31
+ super()
32
+ this.store = new FunnelJsonConnectorStore<SlackConnectorConfig>({
33
+ type: "slack",
34
+ schema: slackConnectorSchema,
35
+ fs: deps.fs,
36
+ dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
37
+ })
38
+ Object.freeze(this)
39
+ }
40
+
41
+ list(): SlackConnectorConfig[] {
42
+ return this.store.list()
43
+ }
44
+
45
+ get(name: string): SlackConnectorConfig | null {
46
+ return this.store.get(name)
47
+ }
48
+
49
+ has(name: string): boolean {
50
+ return this.store.has(name)
51
+ }
52
+
53
+ add(config: SlackConnectorConfig): void {
54
+ if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
55
+
56
+ this.store.write(config)
57
+ }
58
+
59
+ update(name: string, fields: SlackUpdateFields): void {
60
+ const current = this.store.get(name)
61
+
62
+ if (!current) throw new Error(`connector "${name}" not found`)
63
+
64
+ this.store.write({
65
+ ...current,
66
+ botToken: fields.botToken ?? current.botToken,
67
+ appToken: fields.appToken ?? current.appToken,
68
+ })
69
+ }
70
+
71
+ remove(name: string): void {
72
+ this.store.remove(name)
73
+ }
74
+
75
+ rename(oldName: string, newName: string): void {
76
+ this.store.rename(oldName, newName)
77
+ }
78
+
79
+ createListener(config: SlackConnectorConfig): FunnelConnectorListener {
80
+ return new FunnelSlackListener({ config })
81
+ }
82
+
83
+ createAdapter(config: SlackConnectorConfig): FunnelConnectorAdapter {
84
+ return new FunnelSlackAdapter({ config })
85
+ }
86
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from "zod"
2
+
3
+ export const ghConnectorSchema = z.object({
4
+ type: z.literal("gh"),
5
+ name: z.string(),
6
+ pollInterval: z.number().int().positive().optional(),
7
+ })
8
+
9
+ export type GhConnectorConfig = z.infer<typeof ghConnectorSchema>
@@ -0,0 +1,72 @@
1
+ type Field = { min: number; max: number; values: Set<number> }
2
+
3
+ const parseField = (expr: string, min: number, max: number): Field => {
4
+ const values = new Set<number>()
5
+
6
+ for (const part of expr.split(",")) {
7
+ const [rangePart, stepPart] = part.split("/")
8
+ const step = stepPart ? Number(stepPart) : 1
9
+
10
+ if (!Number.isFinite(step) || step <= 0) {
11
+ throw new Error(`invalid cron step: "${stepPart}"`)
12
+ }
13
+
14
+ let lo = min
15
+ let hi = max
16
+
17
+ if (rangePart === "*" || rangePart === undefined || rangePart === "") {
18
+ lo = min
19
+ hi = max
20
+ } else if (rangePart.includes("-")) {
21
+ const [a, b] = rangePart.split("-").map(Number)
22
+
23
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
24
+ throw new Error(`invalid cron range: "${rangePart}"`)
25
+ }
26
+
27
+ lo = a as number
28
+ hi = b as number
29
+ } else {
30
+ const n = Number(rangePart)
31
+
32
+ if (!Number.isFinite(n)) throw new Error(`invalid cron value: "${rangePart}"`)
33
+
34
+ lo = n
35
+ hi = stepPart ? max : n
36
+ }
37
+
38
+ if (lo < min || hi > max || lo > hi) {
39
+ throw new Error(`cron value out of range: ${rangePart} (must be ${min}-${max})`)
40
+ }
41
+
42
+ for (let i = lo; i <= hi; i += step) {
43
+ values.add(i)
44
+ }
45
+ }
46
+
47
+ return { min, max, values }
48
+ }
49
+
50
+ export const matchCron = (expr: string, date: Date): boolean => {
51
+ const parts = expr.trim().split(/\s+/)
52
+
53
+ if (parts.length !== 5) {
54
+ throw new Error(`cron must have 5 fields (got ${parts.length}): "${expr}"`)
55
+ }
56
+
57
+ const [minute, hour, dom, month, dow] = parts as [string, string, string, string, string]
58
+
59
+ const fields = [
60
+ { field: parseField(minute, 0, 59), value: date.getMinutes() },
61
+ { field: parseField(hour, 0, 23), value: date.getHours() },
62
+ { field: parseField(dom, 1, 31), value: date.getDate() },
63
+ { field: parseField(month, 1, 12), value: date.getMonth() + 1 },
64
+ { field: parseField(dow, 0, 6), value: date.getDay() },
65
+ ]
66
+
67
+ for (const { field, value } of fields) {
68
+ if (!field.values.has(value)) return false
69
+ }
70
+
71
+ return true
72
+ }
@@ -0,0 +1,77 @@
1
+ import { join } from "node:path"
2
+ import { connectorConfigSchema } from "@/modules/connectors/connector-config-schema"
3
+ import type { ConnectorStoresBundle } from "@/modules/connectors/funnel-connector-stores"
4
+ import { DEFAULT_FUNNEL_DIR } from "@/modules/connectors/funnel-json-connector-store"
5
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
6
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
7
+ import { logger } from "@/modules/logger"
8
+
9
+ type Props = {
10
+ stores: ConnectorStoresBundle
11
+ fs?: FunnelFileSystem
12
+ dir?: string
13
+ }
14
+
15
+ const defaultFs = new NodeFunnelFileSystem()
16
+
17
+ export const migrateLegacyConnectors = (props: Props): number => {
18
+ const fs = props.fs ?? defaultFs
19
+ const base = props.dir ?? DEFAULT_FUNNEL_DIR
20
+ const path = join(base, "settings.json")
21
+
22
+ if (!fs.existsSync(path)) return 0
23
+
24
+ const content = fs.readFileSync(path)
25
+ const raw = JSON.parse(content) as Record<string, unknown>
26
+ const legacy = raw.connectors
27
+
28
+ if (!Array.isArray(legacy) || legacy.length === 0) {
29
+ if (legacy !== undefined) {
30
+ const stripped = { ...raw }
31
+ delete stripped.connectors
32
+ fs.writeFileSync(path, `${JSON.stringify(stripped, null, 2)}\n`)
33
+ }
34
+ return 0
35
+ }
36
+
37
+ let migrated = 0
38
+
39
+ for (const entry of legacy) {
40
+ const parsed = connectorConfigSchema.safeParse(entry)
41
+
42
+ if (!parsed.success) {
43
+ logger.warn("skipping invalid legacy connector", {
44
+ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", "),
45
+ })
46
+ continue
47
+ }
48
+
49
+ const config = parsed.data
50
+
51
+ if (config.type === "slack") {
52
+ if (props.stores.slack.has(config.name)) continue
53
+ props.stores.slack.add(config)
54
+ } else if (config.type === "gh") {
55
+ if (props.stores.gh.has(config.name)) continue
56
+ props.stores.gh.add(config)
57
+ } else if (config.type === "discord") {
58
+ if (props.stores.discord.has(config.name)) continue
59
+ props.stores.discord.add(config)
60
+ } else {
61
+ if (props.stores.schedule.has(config.name)) continue
62
+ props.stores.schedule.add(config)
63
+ }
64
+
65
+ migrated++
66
+ }
67
+
68
+ const stripped = { ...raw }
69
+ delete stripped.connectors
70
+ fs.writeFileSync(path, `${JSON.stringify(stripped, null, 2)}\n`)
71
+
72
+ if (migrated > 0) {
73
+ logger.info("migrated legacy connectors from settings.json", { count: migrated })
74
+ }
75
+
76
+ return migrated
77
+ }
@@ -0,0 +1,18 @@
1
+ import { z } from "zod"
2
+
3
+ export const scheduleEntrySchema = z.object({
4
+ id: z.string(),
5
+ cron: z.string(),
6
+ prompt: z.string(),
7
+ enabled: z.boolean().default(true),
8
+ })
9
+
10
+ export type ScheduleEntry = z.infer<typeof scheduleEntrySchema>
11
+
12
+ export const scheduleConnectorSchema = z.object({
13
+ type: z.literal("schedule"),
14
+ name: z.string(),
15
+ entries: z.array(scheduleEntrySchema).default([]),
16
+ })
17
+
18
+ export type ScheduleConnectorConfig = z.infer<typeof scheduleConnectorSchema>
@@ -0,0 +1,48 @@
1
+ import { dirname, join } from "node:path"
2
+ import { DEFAULT_FUNNEL_DIR } from "@/modules/connectors/funnel-json-connector-store"
3
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
4
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
5
+
6
+ type Deps = {
7
+ connector: string
8
+ fs?: FunnelFileSystem
9
+ dir?: string
10
+ }
11
+
12
+ const defaultFs = new NodeFunnelFileSystem()
13
+
14
+ export class ScheduleLastFiredStore {
15
+ private readonly path: string
16
+ private readonly fs: FunnelFileSystem
17
+
18
+ constructor(deps: Deps) {
19
+ this.fs = deps.fs ?? defaultFs
20
+ const base = deps.dir ?? DEFAULT_FUNNEL_DIR
21
+ this.path = join(base, "connectors", "schedule", `${deps.connector}.state.json`)
22
+ Object.freeze(this)
23
+ }
24
+
25
+ load(): Map<string, Date> {
26
+ if (!this.fs.existsSync(this.path)) return new Map()
27
+
28
+ const raw = JSON.parse(this.fs.readFileSync(this.path)) as Record<string, string>
29
+ const map = new Map<string, Date>()
30
+
31
+ for (const [id, iso] of Object.entries(raw)) {
32
+ map.set(id, new Date(iso))
33
+ }
34
+
35
+ return map
36
+ }
37
+
38
+ save(state: Map<string, Date>): void {
39
+ const obj: Record<string, string> = {}
40
+
41
+ for (const [id, date] of state) {
42
+ obj[id] = date.toISOString()
43
+ }
44
+
45
+ this.fs.mkdirSync(dirname(this.path), { recursive: true })
46
+ this.fs.writeFileSync(this.path, `${JSON.stringify(obj, null, 2)}\n`)
47
+ }
48
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from "zod"
2
+
3
+ export const slackConnectorSchema = z.object({
4
+ type: z.literal("slack"),
5
+ name: z.string(),
6
+ botToken: z.string().startsWith("xoxb-"),
7
+ appToken: z.string().startsWith("xapp-"),
8
+ })
9
+
10
+ export type SlackConnectorConfig = z.infer<typeof slackConnectorSchema>
@@ -4,10 +4,14 @@ import { join } from "node:path"
4
4
  import type { ServerWebSocket } from "bun"
5
5
  import { Hono } from "hono"
6
6
  import { logger } from "@/modules/logger"
7
- import { resolveListener } from "@/modules/connectors/resolve-listener"
7
+ import { FunnelChannels } from "@/modules/channels/funnel-channels"
8
+ import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
9
+ import { createConnectorStores } from "@/modules/connectors/funnel-connector-stores"
10
+ import { migrateLegacyConnectors } from "@/modules/connectors/migrate-legacy-connectors"
8
11
  import { FunnelBroadcaster } from "@/modules/gateway/funnel-broadcaster"
9
12
  import { FunnelEventLogger } from "@/modules/gateway/funnel-event-logger"
10
13
  import { killCompetingSlackGateways } from "@/modules/gateway/kill-competing-slack-gateways"
14
+ import { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
11
15
  import { FUNNEL_DIR, FunnelSettingsStore } from "@/modules/settings/funnel-settings-store"
12
16
 
13
17
  const PORT = Number(process.env.FUNNEL_PORT) || 9742
@@ -45,6 +49,21 @@ process.on("SIGINT", () => process.exit(130))
45
49
  process.on("SIGTERM", () => process.exit(143))
46
50
 
47
51
  const store = new FunnelSettingsStore()
52
+ const connectorStores = createConnectorStores()
53
+
54
+ migrateLegacyConnectors({ stores: connectorStores })
55
+
56
+ const profiles = new FunnelProfiles({ store })
57
+ const channels: FunnelChannels = new FunnelChannels({
58
+ store,
59
+ connectorChecker: { has: (name: string) => connectors.has(name) },
60
+ profileChecker: profiles,
61
+ profileRefUpdater: profiles,
62
+ })
63
+ const connectors: FunnelConnectors = new FunnelConnectors({
64
+ ...connectorStores,
65
+ refUpdater: channels,
66
+ })
48
67
 
49
68
  const eventLogger = new FunnelEventLogger({ logDir: LOG_DIR })
50
69
  const broadcaster = new FunnelBroadcaster()
@@ -146,11 +165,11 @@ const notify = async (
146
165
  broadcaster.broadcast(content, withConnector)
147
166
  }
148
167
 
149
- const settings = store.read()
168
+ const allConnectors = connectors.list()
150
169
 
151
170
  // Multiple Slack Socket Mode connections sharing one App Token steal DMs/mentions
152
171
  // from each other. Terminate other bun + gateway/bolt/slack processes first.
153
- if (settings.connectors.some((c) => c.type === "slack")) {
172
+ if (allConnectors.some((c) => c.type === "slack")) {
154
173
  const killed = await killCompetingSlackGateways({ selfPid: process.pid })
155
174
 
156
175
  if (killed.length > 0) {
@@ -162,25 +181,23 @@ if (settings.connectors.some((c) => c.type === "slack")) {
162
181
  }
163
182
  }
164
183
 
165
- for (const connector of settings.connectors) {
184
+ for (const { config, listener } of connectors.createListeners()) {
166
185
  const bind = (content: string, meta?: Record<string, string>) =>
167
- notify(connector.name, content, meta)
186
+ notify(config.name, content, meta)
168
187
 
169
188
  try {
170
- const listener = resolveListener(connector)
171
-
172
189
  await listener.start(bind)
173
190
 
174
- eventLogger.log(`${connector.type} listener started: ${connector.name}`, {
191
+ eventLogger.log(`${config.type} listener started: ${config.name}`, {
175
192
  event_type: "system",
176
- action: `${connector.type}_connect`,
177
- connector: connector.name,
193
+ action: `${config.type}_connect`,
194
+ connector: config.name,
178
195
  })
179
196
 
180
- logger.info(`${connector.type} listener started`, { connector: connector.name })
197
+ logger.info(`${config.type} listener started`, { connector: config.name })
181
198
  } catch (error) {
182
- logger.error(`${connector.type} listener failed`, {
183
- connector: connector.name,
199
+ logger.error(`${config.type} listener failed`, {
200
+ connector: config.name,
184
201
  error: error instanceof Error ? error.message : String(error),
185
202
  })
186
203
  }
@@ -16,8 +16,7 @@ export const startChannelServer = async (): Promise<void> => {
16
16
  instructions: [
17
17
  `Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
18
18
  "",
19
- 'event_type="slack": a Slack message. meta includes channel_id, user_id, mentioned, thread_ts, etc. content is the Slack event JSON.',
20
- 'event_type="system": system event (connect / disconnect / startup, etc.).',
19
+ "To reply or act on an event, run `funnel request <platform> --help` via the Bash tool (e.g. `funnel request slack --help`). For general CLI usage, run `funnel --help`.",
21
20
  ].join("\n"),
22
21
  },
23
22
  )