@interactive-inc/claude-funnel 0.3.0 → 0.4.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.
Files changed (52) hide show
  1. package/README.md +71 -21
  2. package/lib/funnel.ts +46 -2
  3. package/lib/index.ts +4 -0
  4. package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
  5. package/lib/modules/channels/funnel-channels.ts +49 -7
  6. package/lib/modules/connectors/connector-config-schema.ts +16 -0
  7. package/lib/modules/connectors/connector-existence-checker.ts +3 -0
  8. package/lib/modules/connectors/discord-connector-schema.ts +9 -0
  9. package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
  10. package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
  11. package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
  12. package/lib/modules/connectors/funnel-connectors.ts +98 -77
  13. package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
  14. package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
  15. package/lib/modules/connectors/funnel-discord-store.ts +84 -0
  16. package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
  17. package/lib/modules/connectors/funnel-gh-store.ts +84 -0
  18. package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
  19. package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
  20. package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
  21. package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
  22. package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
  23. package/lib/modules/connectors/funnel-slack-store.ts +86 -0
  24. package/lib/modules/connectors/gh-connector-schema.ts +9 -0
  25. package/lib/modules/connectors/match-cron.ts +72 -0
  26. package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
  27. package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
  28. package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
  29. package/lib/modules/connectors/slack-connector-schema.ts +10 -0
  30. package/lib/modules/gateway/daemon.ts +30 -13
  31. package/lib/modules/profiles/funnel-profiles.ts +18 -0
  32. package/lib/modules/profiles/profile-channel-checker.ts +3 -0
  33. package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
  34. package/lib/modules/schedule/funnel-schedule.ts +34 -0
  35. package/lib/modules/settings/funnel-settings-store.ts +0 -1
  36. package/lib/modules/settings/mock-funnel-settings-reader.ts +0 -1
  37. package/lib/modules/settings/settings-schema.ts +0 -34
  38. package/lib/routes/connectors/add.help.ts +10 -4
  39. package/lib/routes/connectors/add.ts +10 -1
  40. package/lib/routes/connectors/routes.ts +6 -0
  41. package/lib/routes/connectors/schedules-add.help.ts +11 -0
  42. package/lib/routes/connectors/schedules-add.ts +33 -0
  43. package/lib/routes/connectors/schedules-group.help.ts +1 -0
  44. package/lib/routes/connectors/schedules-group.ts +38 -0
  45. package/lib/routes/connectors/schedules-remove.help.ts +3 -0
  46. package/lib/routes/connectors/schedules-remove.ts +17 -0
  47. package/lib/routes/connectors/set.ts +47 -5
  48. package/lib/routes/connectors/show.ts +9 -0
  49. package/lib/routes/request/discord.ts +1 -1
  50. package/lib/routes/request/slack.ts +1 -1
  51. package/package.json +4 -4
  52. package/lib/modules/connectors/resolve-listener.ts +0 -13
@@ -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
  }
@@ -69,6 +69,24 @@ export class FunnelProfiles {
69
69
  this.store.write(settings)
70
70
  }
71
71
 
72
+ hasChannelRef(channelName: string): boolean {
73
+ return this.store.read().profiles.some((p) => p.channel === channelName)
74
+ }
75
+
76
+ renameChannelRef(oldName: string, newName: string): void {
77
+ const settings = this.store.read()
78
+ let changed = false
79
+
80
+ for (const profile of settings.profiles) {
81
+ if (profile.channel === oldName) {
82
+ profile.channel = newName
83
+ changed = true
84
+ }
85
+ }
86
+
87
+ if (changed) this.store.write(settings)
88
+ }
89
+
72
90
  update(name: string, fields: Partial<Omit<ProfileConfig, "name">>): void {
73
91
  const settings = this.store.read()
74
92
 
@@ -0,0 +1,3 @@
1
+ export type ProfileChannelChecker = {
2
+ hasChannelRef(channelName: string): boolean
3
+ }
@@ -0,0 +1,3 @@
1
+ export type ProfileChannelRefUpdater = {
2
+ renameChannelRef(oldName: string, newName: string): void
3
+ }
@@ -0,0 +1,34 @@
1
+ import type { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
2
+ import type { ScheduleEntry } from "@/modules/connectors/schedule-connector-schema"
3
+
4
+ type Deps = {
5
+ store: FunnelScheduleStore
6
+ }
7
+
8
+ export class FunnelSchedule {
9
+ private readonly store: FunnelScheduleStore
10
+
11
+ constructor(deps: Deps) {
12
+ this.store = deps.store
13
+ Object.freeze(this)
14
+ }
15
+
16
+ listEntries(connector: string): ScheduleEntry[] {
17
+ const config = this.store.get(connector)
18
+
19
+ if (!config) throw new Error(`connector "${connector}" not found`)
20
+
21
+ return config.entries
22
+ }
23
+
24
+ addEntry(
25
+ connector: string,
26
+ entry: Omit<ScheduleEntry, "id"> & { id?: string },
27
+ ): ScheduleEntry {
28
+ return this.store.addEntry(connector, entry)
29
+ }
30
+
31
+ removeEntry(connector: string, id: string): void {
32
+ this.store.removeEntry(connector, id)
33
+ }
34
+ }
@@ -30,7 +30,6 @@ export class FunnelSettingsStore extends FunnelSettingsReader {
30
30
  read(): Settings {
31
31
  if (!this.fs.existsSync(this.path)) {
32
32
  return {
33
- connectors: [],
34
33
  channels: [],
35
34
  repositories: [],
36
35
  profiles: [],
@@ -2,7 +2,6 @@ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
2
  import type { Settings } from "@/modules/settings/settings-schema"
3
3
 
4
4
  export const createSettings = (partial: Partial<Settings> = {}): Settings => ({
5
- connectors: [],
6
5
  channels: [],
7
6
  repositories: [],
8
7
  profiles: [],
@@ -1,38 +1,5 @@
1
1
  import { z } from "zod"
2
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>
11
-
12
- export const ghConnectorSchema = z.object({
13
- type: z.literal("gh"),
14
- name: z.string(),
15
- pollInterval: z.number().int().positive().optional(),
16
- })
17
-
18
- export type GhConnectorConfig = z.infer<typeof ghConnectorSchema>
19
-
20
- export const discordConnectorSchema = z.object({
21
- type: z.literal("discord"),
22
- name: z.string(),
23
- botToken: z.string().min(10),
24
- })
25
-
26
- export type DiscordConnectorConfig = z.infer<typeof discordConnectorSchema>
27
-
28
- export const connectorConfigSchema = z.discriminatedUnion("type", [
29
- slackConnectorSchema,
30
- ghConnectorSchema,
31
- discordConnectorSchema,
32
- ])
33
-
34
- export type ConnectorConfig = z.infer<typeof connectorConfigSchema>
35
-
36
3
  export const channelConfigSchema = z.object({
37
4
  name: z.string(),
38
5
  connectors: z.array(z.string()).default([]),
@@ -58,7 +25,6 @@ export const profileConfigSchema = z.object({
58
25
  export type ProfileConfig = z.infer<typeof profileConfigSchema>
59
26
 
60
27
  export const settingsSchema = z.object({
61
- connectors: z.array(connectorConfigSchema).default([]),
62
28
  channels: z.array(channelConfigSchema).default([]),
63
29
  repositories: z.array(repositoryConfigSchema).default([]),
64
30
  profiles: z.array(profileConfigSchema).default([]),
@@ -1,9 +1,10 @@
1
1
  export const help = `funnel connectors add — add a connector
2
2
 
3
3
  usage:
4
- funnel connectors add <name> --type slack --bot-token xoxb-... --app-token xapp-...
5
- funnel connectors add <name> --type gh [--poll-interval <seconds>]
6
- funnel connectors add <name> --type discord --bot-token <discord-bot-token>
4
+ funnel connectors add <name> --type slack --bot-token xoxb-... --app-token xapp-...
5
+ funnel connectors add <name> --type gh [--poll-interval <seconds>]
6
+ funnel connectors add <name> --type discord --bot-token <discord-bot-token>
7
+ funnel connectors add <name> --type schedule
7
8
 
8
9
  slack (Socket Mode):
9
10
  --bot-token Slack Bot Token (starts with xoxb-)
@@ -16,7 +17,12 @@ gh (GitHub, gh CLI):
16
17
  discord (Discord Gateway):
17
18
  --bot-token Discord Bot Token
18
19
 
20
+ schedule (cron-driven prompts):
21
+ no extra flags at connector level — add entries with:
22
+ funnel connectors <name> schedules add --cron "* * * * *" --prompt "..."
23
+
19
24
  examples:
20
25
  funnel connectors add prod-slack --type slack --bot-token xoxb-... --app-token xapp-...
21
26
  funnel connectors add my-gh --type gh --poll-interval 30
22
- funnel connectors add my-discord --type discord --bot-token MTI...`
27
+ funnel connectors add my-discord --type discord --bot-token MTI...
28
+ funnel connectors add my-cron --type schedule`
@@ -21,6 +21,9 @@ export const connectorsAddHandler = factory.createHandlers(
21
21
  type: z.literal("discord"),
22
22
  "bot-token": z.string().min(10),
23
23
  }),
24
+ z.object({
25
+ type: z.literal("schedule"),
26
+ }),
24
27
  ]),
25
28
  help,
26
29
  ),
@@ -42,12 +45,18 @@ export const connectorsAddHandler = factory.createHandlers(
42
45
  name: param.name,
43
46
  pollInterval: query["poll-interval"] ? Number(query["poll-interval"]) : undefined,
44
47
  })
45
- } else {
48
+ } else if (query.type === "discord") {
46
49
  funnel.connectors.add({
47
50
  type: "discord",
48
51
  name: param.name,
49
52
  botToken: query["bot-token"],
50
53
  })
54
+ } else {
55
+ funnel.connectors.add({
56
+ type: "schedule",
57
+ name: param.name,
58
+ entries: [],
59
+ })
51
60
  }
52
61
 
53
62
  return c.text(`added connector "${param.name}"`)
@@ -3,6 +3,9 @@ import { connectorsAddHandler } from "@/routes/connectors/add"
3
3
  import { connectorsGroupHandler } from "@/routes/connectors/group"
4
4
  import { connectorsRemoveHandler } from "@/routes/connectors/remove"
5
5
  import { connectorsRenameHandler } from "@/routes/connectors/rename"
6
+ import { connectorsSchedulesAddHandler } from "@/routes/connectors/schedules-add"
7
+ import { connectorsSchedulesGroupHandler } from "@/routes/connectors/schedules-group"
8
+ import { connectorsSchedulesRemoveHandler } from "@/routes/connectors/schedules-remove"
6
9
  import { connectorsSetHandler } from "@/routes/connectors/set"
7
10
  import { connectorsShowHandler } from "@/routes/connectors/show"
8
11
 
@@ -11,6 +14,9 @@ export const connectorsRoutes = factory
11
14
  .get("/", ...connectorsGroupHandler)
12
15
  .put("/:name/rename/:newName", ...connectorsRenameHandler)
13
16
  .put("/rename/:name/:newName", ...connectorsRenameHandler)
17
+ .post("/:name/schedules", ...connectorsSchedulesAddHandler)
18
+ .get("/:name/schedules", ...connectorsSchedulesGroupHandler)
19
+ .delete("/:name/schedules/:id", ...connectorsSchedulesRemoveHandler)
14
20
  .post("/:name", ...connectorsAddHandler)
15
21
  .put("/:name", ...connectorsSetHandler)
16
22
  .delete("/:name", ...connectorsRemoveHandler)
@@ -0,0 +1,11 @@
1
+ export const help = `funnel connectors <name> schedules add — add a schedule entry
2
+
3
+ usage: funnel connectors <name> schedules add --cron "<expr>" --prompt "<text>" [--disabled]
4
+
5
+ options:
6
+ --cron 5-field cron expression (min hour dom month dow)
7
+ --prompt prompt text delivered to subscribing channels when the cron fires
8
+ --disabled create the entry in disabled state
9
+
10
+ example:
11
+ funnel connectors my-cron schedules add --cron "*/5 * * * *" --prompt "status check"`
@@ -0,0 +1,33 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { matchCron } from "@/modules/connectors/match-cron"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/connectors/schedules-add.help"
6
+
7
+ export const connectorsSchedulesAddHandler = factory.createHandlers(
8
+ zValidator("param", z.object({ name: z.string() })),
9
+ zValidator(
10
+ "query",
11
+ z.object({
12
+ cron: z.string(),
13
+ prompt: z.string(),
14
+ disabled: z.string().optional(),
15
+ }),
16
+ help,
17
+ ),
18
+ (c) => {
19
+ const param = c.req.valid("param")
20
+ const query = c.req.valid("query")
21
+ const funnel = c.var.funnel
22
+
23
+ matchCron(query.cron, new Date())
24
+
25
+ const entry = funnel.schedule.addEntry(param.name, {
26
+ cron: query.cron,
27
+ prompt: query.prompt,
28
+ enabled: query.disabled !== "true",
29
+ })
30
+
31
+ return c.text(`added schedule entry "${entry.id}" to connector "${param.name}"`)
32
+ },
33
+ )
@@ -0,0 +1 @@
1
+ export const help = `funnel connectors <name> schedules — list schedule entries`