@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.
- package/README.md +82 -26
- package/lib/funnel.ts +49 -5
- package/lib/index.ts +8 -2
- package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
- package/lib/modules/channels/funnel-channels.ts +50 -8
- package/lib/modules/claude/funnel-claude.ts +79 -1
- 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/mcp/channel-server.ts +1 -2
- package/lib/modules/profiles/funnel-profiles.ts +123 -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/repos/funnel-repositories.ts +4 -4
- package/lib/modules/router/to-request.ts +2 -5
- package/lib/modules/schedule/funnel-schedule.ts +34 -0
- package/lib/modules/settings/funnel-settings-store.ts +1 -2
- package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -2
- package/lib/modules/settings/settings-schema.ts +3 -37
- package/lib/routes/claude/claude.help.ts +9 -4
- package/lib/routes/claude/claude.ts +44 -7
- package/lib/routes/connectors/add.help.ts +10 -4
- package/lib/routes/connectors/add.ts +10 -1
- package/lib/routes/connectors/routes.ts +6 -2
- 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/profiles/add.help.ts +3 -0
- package/lib/routes/{agents → profiles}/add.ts +4 -4
- package/lib/routes/profiles/group.help.ts +16 -0
- package/lib/routes/profiles/group.ts +25 -0
- package/lib/routes/profiles/launch.help.ts +4 -0
- package/lib/routes/{agents → profiles}/launch.ts +9 -8
- package/lib/routes/profiles/remove.help.ts +3 -0
- package/lib/routes/{agents → profiles}/remove.ts +4 -4
- package/lib/routes/profiles/rename.help.ts +5 -0
- package/lib/routes/{agents → profiles}/rename.ts +4 -4
- package/lib/routes/profiles/routes.ts +18 -0
- package/lib/routes/profiles/set.help.ts +5 -0
- package/lib/routes/{agents → profiles}/set.ts +4 -4
- package/lib/routes/repos/add.help.ts +3 -2
- package/lib/routes/repos/add.ts +3 -2
- package/lib/routes/repos/group.help.ts +1 -1
- package/lib/routes/request/discord-help.ts +9 -0
- package/lib/routes/request/discord.help.ts +19 -0
- package/lib/routes/request/discord.ts +65 -0
- package/lib/routes/request/group.help.ts +15 -0
- package/lib/routes/request/group.ts +9 -0
- package/lib/routes/request/routes.ts +14 -0
- package/lib/routes/request/slack-help.ts +9 -0
- package/lib/routes/request/slack.help.ts +19 -0
- package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
- package/lib/routes/status/status.help.ts +1 -1
- package/lib/routes/status/status.ts +7 -7
- package/lib/routes/update/routes.ts +4 -0
- package/lib/routes/update/update.help.ts +5 -0
- package/lib/routes/update/update.ts +21 -0
- package/lib/routes.ts +6 -2
- package/package.json +1 -1
- package/lib/modules/agents/funnel-agents.ts +0 -105
- package/lib/modules/connectors/resolve-listener.ts +0 -13
- package/lib/routes/agents/add.help.ts +0 -3
- package/lib/routes/agents/group.help.ts +0 -13
- package/lib/routes/agents/group.ts +0 -25
- package/lib/routes/agents/launch.help.ts +0 -3
- package/lib/routes/agents/remove.help.ts +0 -3
- package/lib/routes/agents/rename.help.ts +0 -5
- package/lib/routes/agents/routes.ts +0 -17
- package/lib/routes/agents/set.help.ts +0 -5
- 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/
|
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
)
|