@interactive-inc/claude-funnel 0.3.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.
- package/README.md +66 -16
- package/lib/funnel.ts +46 -2
- package/lib/index.ts +4 -0
- package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
- package/lib/modules/channels/funnel-channels.ts +49 -7
- package/lib/modules/connectors/connector-config-schema.ts +16 -0
- package/lib/modules/connectors/connector-existence-checker.ts +3 -0
- package/lib/modules/connectors/discord-connector-schema.ts +9 -0
- package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
- package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
- package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
- package/lib/modules/connectors/funnel-connectors.ts +98 -77
- package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
- package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
- package/lib/modules/connectors/funnel-discord-store.ts +84 -0
- package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
- package/lib/modules/connectors/funnel-gh-store.ts +84 -0
- package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
- package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
- package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
- package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
- package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
- package/lib/modules/connectors/funnel-slack-store.ts +86 -0
- package/lib/modules/connectors/gh-connector-schema.ts +9 -0
- package/lib/modules/connectors/match-cron.ts +72 -0
- package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
- package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
- package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
- package/lib/modules/connectors/slack-connector-schema.ts +10 -0
- package/lib/modules/gateway/daemon.ts +30 -13
- package/lib/modules/profiles/funnel-profiles.ts +18 -0
- package/lib/modules/profiles/profile-channel-checker.ts +3 -0
- package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
- package/lib/modules/schedule/funnel-schedule.ts +34 -0
- package/lib/modules/settings/funnel-settings-store.ts +0 -1
- package/lib/modules/settings/mock-funnel-settings-reader.ts +0 -1
- package/lib/modules/settings/settings-schema.ts +0 -34
- package/lib/routes/connectors/add.help.ts +10 -4
- package/lib/routes/connectors/add.ts +10 -1
- package/lib/routes/connectors/routes.ts +6 -0
- package/lib/routes/connectors/schedules-add.help.ts +11 -0
- package/lib/routes/connectors/schedules-add.ts +33 -0
- package/lib/routes/connectors/schedules-group.help.ts +1 -0
- package/lib/routes/connectors/schedules-group.ts +38 -0
- package/lib/routes/connectors/schedules-remove.help.ts +3 -0
- package/lib/routes/connectors/schedules-remove.ts +17 -0
- package/lib/routes/connectors/set.ts +47 -5
- package/lib/routes/connectors/show.ts +9 -0
- package/lib/routes/request/discord.ts +1 -1
- package/lib/routes/request/slack.ts +1 -1
- package/package.json +1 -1
- package/lib/modules/connectors/resolve-listener.ts +0 -13
|
@@ -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 {
|
|
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
|
|
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 (
|
|
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
|
|
184
|
+
for (const { config, listener } of connectors.createListeners()) {
|
|
166
185
|
const bind = (content: string, meta?: Record<string, string>) =>
|
|
167
|
-
notify(
|
|
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(`${
|
|
191
|
+
eventLogger.log(`${config.type} listener started: ${config.name}`, {
|
|
175
192
|
event_type: "system",
|
|
176
|
-
action: `${
|
|
177
|
-
connector:
|
|
193
|
+
action: `${config.type}_connect`,
|
|
194
|
+
connector: config.name,
|
|
178
195
|
})
|
|
179
196
|
|
|
180
|
-
logger.info(`${
|
|
197
|
+
logger.info(`${config.type} listener started`, { connector: config.name })
|
|
181
198
|
} catch (error) {
|
|
182
|
-
logger.error(`${
|
|
183
|
-
connector:
|
|
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,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
|
+
}
|
|
@@ -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
|
|
5
|
-
funnel connectors add <name> --type gh
|
|
6
|
-
funnel connectors add <name> --type discord
|
|
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`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { factory } from "@/factory"
|
|
4
|
+
import { zValidator } from "@/modules/router/validator"
|
|
5
|
+
import { help } from "@/routes/connectors/schedules-group.help"
|
|
6
|
+
|
|
7
|
+
export const connectorsSchedulesGroupHandler = factory.createHandlers(
|
|
8
|
+
zValidator("param", z.object({ name: z.string() })),
|
|
9
|
+
zValidator("query", z.object({}), help),
|
|
10
|
+
(c) => {
|
|
11
|
+
const param = c.req.valid("param")
|
|
12
|
+
const funnel = c.var.funnel
|
|
13
|
+
const connector = funnel.connectors.get(param.name)
|
|
14
|
+
|
|
15
|
+
if (!connector) {
|
|
16
|
+
throw new HTTPException(404, { message: `connector "${param.name}" not found` })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (connector.type !== "schedule") {
|
|
20
|
+
throw new HTTPException(400, {
|
|
21
|
+
message: `connector "${param.name}" is type "${connector.type}", not "schedule"`,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entries = funnel.schedule.listEntries(param.name)
|
|
26
|
+
|
|
27
|
+
if (entries.length === 0) return c.text("no schedule entries")
|
|
28
|
+
|
|
29
|
+
const lines: string[] = []
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const status = entry.enabled ? "" : " (disabled)"
|
|
33
|
+
lines.push(`${entry.id}${status} ${entry.cron} ${entry.prompt}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return c.text(lines.join("\n"))
|
|
37
|
+
},
|
|
38
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { factory } from "@/factory"
|
|
3
|
+
import { zValidator } from "@/modules/router/validator"
|
|
4
|
+
import { help } from "@/routes/connectors/schedules-remove.help"
|
|
5
|
+
|
|
6
|
+
export const connectorsSchedulesRemoveHandler = factory.createHandlers(
|
|
7
|
+
zValidator("param", z.object({ name: z.string(), id: z.string() })),
|
|
8
|
+
zValidator("query", z.object({}), help),
|
|
9
|
+
(c) => {
|
|
10
|
+
const param = c.req.valid("param")
|
|
11
|
+
const funnel = c.var.funnel
|
|
12
|
+
|
|
13
|
+
funnel.schedule.removeEntry(param.name, param.id)
|
|
14
|
+
|
|
15
|
+
return c.text(`removed schedule entry "${param.id}" from connector "${param.name}"`)
|
|
16
|
+
},
|
|
17
|
+
)
|