@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,84 @@
|
|
|
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 { FunnelDiscordAdapter } from "@/modules/connectors/funnel-discord-adapter"
|
|
9
|
+
import { FunnelDiscordListener } from "@/modules/connectors/funnel-discord-listener"
|
|
10
|
+
import {
|
|
11
|
+
type DiscordConnectorConfig,
|
|
12
|
+
discordConnectorSchema,
|
|
13
|
+
} from "@/modules/connectors/discord-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 DiscordUpdateFields = {
|
|
22
|
+
botToken?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FunnelDiscordStore extends FunnelCallableConnectorStore<DiscordConnectorConfig> {
|
|
26
|
+
readonly type = "discord" as const
|
|
27
|
+
private readonly store: FunnelJsonConnectorStore<DiscordConnectorConfig>
|
|
28
|
+
|
|
29
|
+
constructor(deps: Deps = {}) {
|
|
30
|
+
super()
|
|
31
|
+
this.store = new FunnelJsonConnectorStore<DiscordConnectorConfig>({
|
|
32
|
+
type: "discord",
|
|
33
|
+
schema: discordConnectorSchema,
|
|
34
|
+
fs: deps.fs,
|
|
35
|
+
dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
|
|
36
|
+
})
|
|
37
|
+
Object.freeze(this)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
list(): DiscordConnectorConfig[] {
|
|
41
|
+
return this.store.list()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get(name: string): DiscordConnectorConfig | null {
|
|
45
|
+
return this.store.get(name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
has(name: string): boolean {
|
|
49
|
+
return this.store.has(name)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
add(config: DiscordConnectorConfig): void {
|
|
53
|
+
if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
|
|
54
|
+
|
|
55
|
+
this.store.write(config)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
update(name: string, fields: DiscordUpdateFields): void {
|
|
59
|
+
const current = this.store.get(name)
|
|
60
|
+
|
|
61
|
+
if (!current) throw new Error(`connector "${name}" not found`)
|
|
62
|
+
|
|
63
|
+
this.store.write({
|
|
64
|
+
...current,
|
|
65
|
+
botToken: fields.botToken ?? current.botToken,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
remove(name: string): void {
|
|
70
|
+
this.store.remove(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
rename(oldName: string, newName: string): void {
|
|
74
|
+
this.store.rename(oldName, newName)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
createListener(config: DiscordConnectorConfig): FunnelConnectorListener {
|
|
78
|
+
return new FunnelDiscordListener({ config })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
createAdapter(config: DiscordConnectorConfig): FunnelConnectorAdapter {
|
|
82
|
+
return new FunnelDiscordAdapter({ config })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
import { logger } from "@/modules/logger"
|
|
6
6
|
import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
|
|
7
7
|
import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
|
|
8
|
-
import type { GhConnectorConfig } from "@/modules/
|
|
8
|
+
import type { GhConnectorConfig } from "@/modules/connectors/gh-connector-schema"
|
|
9
9
|
|
|
10
10
|
type GhNotification = {
|
|
11
11
|
id: string
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { FunnelGhAdapter } from "@/modules/connectors/funnel-gh-adapter"
|
|
9
|
+
import { FunnelGhListener } from "@/modules/connectors/funnel-gh-listener"
|
|
10
|
+
import {
|
|
11
|
+
type GhConnectorConfig,
|
|
12
|
+
ghConnectorSchema,
|
|
13
|
+
} from "@/modules/connectors/gh-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 GhUpdateFields = {
|
|
22
|
+
pollInterval?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FunnelGhStore extends FunnelCallableConnectorStore<GhConnectorConfig> {
|
|
26
|
+
readonly type = "gh" as const
|
|
27
|
+
private readonly store: FunnelJsonConnectorStore<GhConnectorConfig>
|
|
28
|
+
|
|
29
|
+
constructor(deps: Deps = {}) {
|
|
30
|
+
super()
|
|
31
|
+
this.store = new FunnelJsonConnectorStore<GhConnectorConfig>({
|
|
32
|
+
type: "gh",
|
|
33
|
+
schema: ghConnectorSchema,
|
|
34
|
+
fs: deps.fs,
|
|
35
|
+
dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
|
|
36
|
+
})
|
|
37
|
+
Object.freeze(this)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
list(): GhConnectorConfig[] {
|
|
41
|
+
return this.store.list()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get(name: string): GhConnectorConfig | null {
|
|
45
|
+
return this.store.get(name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
has(name: string): boolean {
|
|
49
|
+
return this.store.has(name)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
add(config: GhConnectorConfig): void {
|
|
53
|
+
if (this.has(config.name)) throw new Error(`connector "${config.name}" already exists`)
|
|
54
|
+
|
|
55
|
+
this.store.write(config)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
update(name: string, fields: GhUpdateFields): void {
|
|
59
|
+
const current = this.store.get(name)
|
|
60
|
+
|
|
61
|
+
if (!current) throw new Error(`connector "${name}" not found`)
|
|
62
|
+
|
|
63
|
+
this.store.write({
|
|
64
|
+
...current,
|
|
65
|
+
pollInterval: fields.pollInterval ?? current.pollInterval,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
remove(name: string): void {
|
|
70
|
+
this.store.remove(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
rename(oldName: string, newName: string): void {
|
|
74
|
+
this.store.rename(oldName, newName)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
createListener(config: GhConnectorConfig): FunnelConnectorListener {
|
|
78
|
+
return new FunnelGhListener({ config })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
createAdapter(_config: GhConnectorConfig): FunnelConnectorAdapter {
|
|
82
|
+
return new FunnelGhAdapter()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { ZodType } from "zod"
|
|
4
|
+
import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
|
|
5
|
+
import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
|
|
6
|
+
|
|
7
|
+
const defaultFs = new NodeFunnelFileSystem()
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel")
|
|
10
|
+
|
|
11
|
+
type Props<TConfig> = {
|
|
12
|
+
type: string
|
|
13
|
+
schema: ZodType<TConfig>
|
|
14
|
+
fs?: FunnelFileSystem
|
|
15
|
+
dir?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class FunnelJsonConnectorStore<TConfig extends { type: string; name: string }> {
|
|
19
|
+
private readonly type: string
|
|
20
|
+
private readonly schema: ZodType<TConfig>
|
|
21
|
+
private readonly fs: FunnelFileSystem
|
|
22
|
+
private readonly dir: string
|
|
23
|
+
|
|
24
|
+
constructor(props: Props<TConfig>) {
|
|
25
|
+
this.type = props.type
|
|
26
|
+
this.schema = props.schema
|
|
27
|
+
this.fs = props.fs ?? defaultFs
|
|
28
|
+
const base = props.dir ?? DEFAULT_FUNNEL_DIR
|
|
29
|
+
this.dir = join(base, "connectors", props.type)
|
|
30
|
+
Object.freeze(this)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
list(): TConfig[] {
|
|
34
|
+
if (!this.fs.existsSync(this.dir)) return []
|
|
35
|
+
|
|
36
|
+
const files = this.fs.readdirSync(this.dir).filter((f) => f.endsWith(".json"))
|
|
37
|
+
const configs: TConfig[] = []
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const name = file.slice(0, -5)
|
|
41
|
+
const config = this.get(name)
|
|
42
|
+
|
|
43
|
+
if (config) configs.push(config)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return configs
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get(name: string): TConfig | null {
|
|
50
|
+
const path = this.pathFor(name)
|
|
51
|
+
|
|
52
|
+
if (!this.fs.existsSync(path)) return null
|
|
53
|
+
|
|
54
|
+
const content = this.fs.readFileSync(path)
|
|
55
|
+
const parsed = JSON.parse(content)
|
|
56
|
+
const result = this.schema.safeParse(parsed)
|
|
57
|
+
|
|
58
|
+
if (!result.success) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`invalid ${this.type} connector "${name}": ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result.data
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
has(name: string): boolean {
|
|
68
|
+
return this.fs.existsSync(this.pathFor(name))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
write(config: TConfig): void {
|
|
72
|
+
this.fs.mkdirSync(this.dir, { recursive: true })
|
|
73
|
+
this.fs.writeFileSync(this.pathFor(config.name), `${JSON.stringify(config, null, 2)}\n`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
remove(name: string): void {
|
|
77
|
+
if (!this.has(name)) throw new Error(`connector "${name}" not found`)
|
|
78
|
+
|
|
79
|
+
this.fs.unlink(this.pathFor(name))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
rename(oldName: string, newName: string): void {
|
|
83
|
+
const config = this.get(oldName)
|
|
84
|
+
|
|
85
|
+
if (!config) throw new Error(`connector "${oldName}" not found`)
|
|
86
|
+
|
|
87
|
+
if (this.has(newName)) {
|
|
88
|
+
throw new Error(`connector "${newName}" already exists`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const renamed = { ...config, name: newName } as TConfig
|
|
92
|
+
|
|
93
|
+
this.write(renamed)
|
|
94
|
+
this.fs.unlink(this.pathFor(oldName))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pathFor(name: string): string {
|
|
98
|
+
return join(this.dir, `${name}.json`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FunnelConnectorListener,
|
|
3
|
+
type NotifyFn,
|
|
4
|
+
} from "@/modules/connectors/funnel-connector-listener"
|
|
5
|
+
import { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
|
|
6
|
+
import { matchCron } from "@/modules/connectors/match-cron"
|
|
7
|
+
import { ScheduleLastFiredStore } from "@/modules/connectors/schedule-last-fired-store"
|
|
8
|
+
import { logger } from "@/modules/logger"
|
|
9
|
+
import type { ScheduleConnectorConfig } from "@/modules/connectors/schedule-connector-schema"
|
|
10
|
+
|
|
11
|
+
type Deps = {
|
|
12
|
+
config: ScheduleConnectorConfig
|
|
13
|
+
store: FunnelScheduleStore
|
|
14
|
+
lastFiredStore: ScheduleLastFiredStore
|
|
15
|
+
now?: () => Date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_CATCHUP_MINUTES = 60 * 24
|
|
19
|
+
|
|
20
|
+
export class FunnelScheduleListener extends FunnelConnectorListener {
|
|
21
|
+
private readonly config: ScheduleConnectorConfig
|
|
22
|
+
private readonly store: FunnelScheduleStore
|
|
23
|
+
private readonly lastFiredStore: ScheduleLastFiredStore
|
|
24
|
+
private readonly now: () => Date
|
|
25
|
+
|
|
26
|
+
constructor(deps: Deps) {
|
|
27
|
+
super()
|
|
28
|
+
this.config = deps.config
|
|
29
|
+
this.store = deps.store
|
|
30
|
+
this.lastFiredStore = deps.lastFiredStore
|
|
31
|
+
this.now = deps.now ?? (() => new Date())
|
|
32
|
+
Object.freeze(this)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start(notify: NotifyFn): Promise<void> {
|
|
36
|
+
const scheduleNext = () => {
|
|
37
|
+
const date = this.now()
|
|
38
|
+
const msUntilNextMinute = 60_000 - (date.getSeconds() * 1000 + date.getMilliseconds())
|
|
39
|
+
const timer = setTimeout(async () => {
|
|
40
|
+
await this.tick(notify)
|
|
41
|
+
scheduleNext()
|
|
42
|
+
}, msUntilNextMinute)
|
|
43
|
+
|
|
44
|
+
timer.unref()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await this.tick(notify)
|
|
48
|
+
scheduleNext()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async tick(notify: NotifyFn): Promise<void> {
|
|
52
|
+
const config = this.store.get(this.config.name)
|
|
53
|
+
|
|
54
|
+
if (!config) return
|
|
55
|
+
|
|
56
|
+
const now = this.truncateToMinute(this.now())
|
|
57
|
+
const state = this.lastFiredStore.load()
|
|
58
|
+
let changed = false
|
|
59
|
+
|
|
60
|
+
for (const entry of config.entries) {
|
|
61
|
+
if (!entry.enabled) continue
|
|
62
|
+
|
|
63
|
+
const lastFired = state.get(entry.id)
|
|
64
|
+
const searchFrom = lastFired ? new Date(lastFired.getTime() + 60_000) : now
|
|
65
|
+
|
|
66
|
+
if (searchFrom.getTime() > now.getTime()) continue
|
|
67
|
+
|
|
68
|
+
const match = this.findMostRecentMatch(entry.cron, searchFrom, now, entry.id)
|
|
69
|
+
|
|
70
|
+
if (!match) continue
|
|
71
|
+
|
|
72
|
+
const meta: Record<string, string> = {
|
|
73
|
+
event_type: "schedule",
|
|
74
|
+
schedule_id: entry.id,
|
|
75
|
+
cron: entry.cron,
|
|
76
|
+
fired_at: match.toISOString(),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (match.getTime() !== now.getTime()) meta.catchup = "true"
|
|
80
|
+
|
|
81
|
+
await notify(entry.prompt, meta)
|
|
82
|
+
state.set(entry.id, match)
|
|
83
|
+
changed = true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (changed) this.lastFiredStore.save(state)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private findMostRecentMatch(
|
|
90
|
+
cron: string,
|
|
91
|
+
from: Date,
|
|
92
|
+
until: Date,
|
|
93
|
+
entryId: string,
|
|
94
|
+
): Date | null {
|
|
95
|
+
const maxIterations = Math.min(
|
|
96
|
+
MAX_CATCHUP_MINUTES,
|
|
97
|
+
Math.floor((until.getTime() - from.getTime()) / 60_000) + 1,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
101
|
+
const candidate = new Date(until.getTime() - i * 60_000)
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (matchCron(cron, candidate)) return candidate
|
|
105
|
+
} catch (error) {
|
|
106
|
+
logger.error("invalid cron expression in schedule", {
|
|
107
|
+
connector: this.config.name,
|
|
108
|
+
id: entryId,
|
|
109
|
+
cron,
|
|
110
|
+
error: error instanceof Error ? error.message : String(error),
|
|
111
|
+
})
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private truncateToMinute(date: Date): Date {
|
|
120
|
+
const copy = new Date(date.getTime())
|
|
121
|
+
copy.setSeconds(0, 0)
|
|
122
|
+
return copy
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -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/
|
|
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/
|
|
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
|
+
}
|