zooid 0.14.0 → 0.15.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/dist/bin.js +64 -28
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-R5S26T7B.js → chunk-35IKQPC5.js} +272 -65
- package/dist/chunk-35IKQPC5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/daemon/start-daemon.ts +3 -0
- package/src/daemon/trigger-runner.mxid.test.ts +145 -0
- package/src/daemon/trigger-runner.test.ts +3 -4
- package/src/daemon/trigger-runner.ts +45 -7
- package/src/daemon/trigger-scheduler.ts +12 -7
- package/src/daemon/webhook-routes.test.ts +31 -1
- package/src/daemon/webhook-routes.ts +43 -12
- package/dist/chunk-R5S26T7B.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"zooid": {
|
|
28
|
-
"webVersion": "0.12.
|
|
28
|
+
"webVersion": "0.12.1"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=22"
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"tar": "^7.5.16",
|
|
44
44
|
"web-push": "^3.6.7",
|
|
45
45
|
"yaml": "^2.5.0",
|
|
46
|
-
"@zooid/acp-client": "^0.
|
|
47
|
-
"@zooid/core": "^0.
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/runtime-docker": "^0.
|
|
50
|
-
"@zooid/
|
|
51
|
-
"@zooid/runtime-local": "^0.
|
|
52
|
-
"@zooid/transport-matrix": "^0.
|
|
53
|
-
"@zooid/transport-http": "^0.
|
|
46
|
+
"@zooid/acp-client": "^0.15.0",
|
|
47
|
+
"@zooid/core": "^0.15.0",
|
|
48
|
+
"@zooid/context-mcp": "^0.15.0",
|
|
49
|
+
"@zooid/runtime-docker": "^0.15.0",
|
|
50
|
+
"@zooid/pi-extension": "^0.13.2",
|
|
51
|
+
"@zooid/runtime-local": "^0.15.0",
|
|
52
|
+
"@zooid/transport-matrix": "^0.15.0",
|
|
53
|
+
"@zooid/transport-http": "^0.15.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "^0.21.0",
|
|
@@ -322,6 +322,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
322
322
|
resolveRoom,
|
|
323
323
|
ensureBot,
|
|
324
324
|
sendMessage: (m) => client.sendMessage(m),
|
|
325
|
+
getJoinedMembers: (roomId, asUserId) => client.getJoinedMembers(roomId, asUserId),
|
|
325
326
|
})
|
|
326
327
|
for (const [name] of webhookTriggers) {
|
|
327
328
|
console.log(`[webhook] POST ${WEBHOOK_ROUTE_PREFIX}/${name}`)
|
|
@@ -383,6 +384,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
383
384
|
resolveRoom,
|
|
384
385
|
ensureBot,
|
|
385
386
|
sendMessage: (m) => client.sendMessage(m),
|
|
387
|
+
getJoinedMembers: (roomId, asUserId) => client.getJoinedMembers(roomId, asUserId),
|
|
386
388
|
})
|
|
387
389
|
const scheduled = Object.values(config.triggers).filter((t) => t.schedule).length
|
|
388
390
|
if (scheduled > 0) console.log(`[trigger] scheduled ${scheduled} trigger(s)`)
|
|
@@ -424,6 +426,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
424
426
|
spaceRoomId,
|
|
425
427
|
asUserId,
|
|
426
428
|
getAgents: () => bindings,
|
|
429
|
+
stateKey: matrix.transport.workstation ?? '',
|
|
427
430
|
})
|
|
428
431
|
console.log(`[matrix] published dev.zooid.workforce (${bindings.length} agents)`)
|
|
429
432
|
void publisher
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { fireTrigger } from './trigger-runner.js'
|
|
3
|
+
|
|
4
|
+
const deps = (mention: string, agentUserIds: Record<string, string>) => {
|
|
5
|
+
const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
|
|
6
|
+
return {
|
|
7
|
+
sendMessage,
|
|
8
|
+
call: () =>
|
|
9
|
+
fireTrigger({
|
|
10
|
+
name: 'standup',
|
|
11
|
+
as: '@cloud.cron:zooid.zoon.eco',
|
|
12
|
+
message: { room: '#product:zooid.zoon.eco', mention, text: 'Morning standup.' },
|
|
13
|
+
agentUserIds,
|
|
14
|
+
resolveRoom: async () => '!room:zooid.zoon.eco',
|
|
15
|
+
ensureBot: async () => {},
|
|
16
|
+
sendMessage,
|
|
17
|
+
}),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('fireTrigger addressing', () => {
|
|
22
|
+
it('uses an MXID mention verbatim, with no local lookup', async () => {
|
|
23
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
24
|
+
await call()
|
|
25
|
+
expect(sendMessage.mock.calls[0]![0].content['m.mentions']).toEqual({
|
|
26
|
+
user_ids: ['@ori-macbook.cpo:zooid.zoon.eco'],
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('resolves a bare name through the local agent map', async () => {
|
|
31
|
+
const { call, sendMessage } = deps('scout', { scout: '@cloud.scout:zooid.zoon.eco' })
|
|
32
|
+
await call()
|
|
33
|
+
expect(sendMessage.mock.calls[0]![0].content['m.mentions']).toEqual({
|
|
34
|
+
user_ids: ['@cloud.scout:zooid.zoon.eco'],
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('stamps dev.zooid.trigger with the trigger name and firing time', async () => {
|
|
39
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
40
|
+
const before = Date.now()
|
|
41
|
+
await call()
|
|
42
|
+
const stamp = sendMessage.mock.calls[0]![0].content['dev.zooid.trigger'] as {
|
|
43
|
+
name: string
|
|
44
|
+
fired_at: number
|
|
45
|
+
ttl_ms?: number
|
|
46
|
+
}
|
|
47
|
+
expect(stamp.name).toBe('standup')
|
|
48
|
+
expect(stamp.fired_at).toBeGreaterThanOrEqual(before)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('skips a bare name with no local binding rather than posting', async () => {
|
|
52
|
+
const { call, sendMessage } = deps('scout', {})
|
|
53
|
+
await call()
|
|
54
|
+
expect(sendMessage).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('fireTrigger mention rendering', () => {
|
|
59
|
+
it('leads the body with the MXID so the room can see who was addressed', async () => {
|
|
60
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
61
|
+
await call()
|
|
62
|
+
expect(sendMessage.mock.calls[0]![0].content.body).toBe(
|
|
63
|
+
'@ori-macbook.cpo:zooid.zoon.eco Morning standup.',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('sends no HTML — the client pills a bare MXID, and markup would need escaping', async () => {
|
|
68
|
+
const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
|
|
69
|
+
await call()
|
|
70
|
+
const c = sendMessage.mock.calls[0]![0].content
|
|
71
|
+
expect(c.format).toBeUndefined()
|
|
72
|
+
expect(c.formatted_body).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('warns when the addressed user is not in the room', async () => {
|
|
76
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
77
|
+
const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
|
|
78
|
+
await fireTrigger({
|
|
79
|
+
name: 'standup',
|
|
80
|
+
as: '@cloud.cron:zooid.zoon.eco',
|
|
81
|
+
message: {
|
|
82
|
+
room: '#product:zooid.zoon.eco',
|
|
83
|
+
mention: '@ori-macbook.cpo:zooid.zoon.eco',
|
|
84
|
+
text: 'Morning standup.',
|
|
85
|
+
},
|
|
86
|
+
agentUserIds: {},
|
|
87
|
+
resolveRoom: async () => '!p:zooid.zoon.eco',
|
|
88
|
+
ensureBot: async () => {},
|
|
89
|
+
sendMessage,
|
|
90
|
+
getJoinedMembers: async () => ({ joined: { '@cloud.product:zooid.zoon.eco': {} } }),
|
|
91
|
+
})
|
|
92
|
+
// posted anyway — the agent may join before it expires
|
|
93
|
+
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/not in .*#product/))
|
|
95
|
+
warn.mockRestore()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('warns only once across repeated fires at the same absent target', async () => {
|
|
99
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
100
|
+
const getJoinedMembers = vi.fn(async () => ({ joined: {} }))
|
|
101
|
+
// A distinct trigger name from the previous test: `warnedTargets` is
|
|
102
|
+
// module-level (dedup lasts the daemon's process lifetime, per spec), so
|
|
103
|
+
// reusing "standup" here would find the key already warned and this
|
|
104
|
+
// test's own two calls would produce zero fresh warnings.
|
|
105
|
+
const fire = () =>
|
|
106
|
+
fireTrigger({
|
|
107
|
+
name: 'standup-repeat',
|
|
108
|
+
as: '@cloud.cron:zooid.zoon.eco',
|
|
109
|
+
message: {
|
|
110
|
+
room: '#product:zooid.zoon.eco',
|
|
111
|
+
mention: '@ori-macbook.cpo:zooid.zoon.eco',
|
|
112
|
+
text: 'Morning standup.',
|
|
113
|
+
},
|
|
114
|
+
agentUserIds: {},
|
|
115
|
+
resolveRoom: async () => '!p:zooid.zoon.eco',
|
|
116
|
+
ensureBot: async () => {},
|
|
117
|
+
sendMessage: async () => ({ event_id: '$1' }),
|
|
118
|
+
getJoinedMembers,
|
|
119
|
+
})
|
|
120
|
+
await fire()
|
|
121
|
+
await fire()
|
|
122
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
123
|
+
warn.mockRestore()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('leaves interpolated webhook text verbatim in the body', async () => {
|
|
127
|
+
const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
|
|
128
|
+
await fireTrigger({
|
|
129
|
+
name: 'triage',
|
|
130
|
+
as: '@cloud.hook:zooid.zoon.eco',
|
|
131
|
+
message: {
|
|
132
|
+
room: '#product:zooid.zoon.eco',
|
|
133
|
+
mention: '@cloud.product:zooid.zoon.eco',
|
|
134
|
+
text: 'Triage "<img src=x> & more".',
|
|
135
|
+
},
|
|
136
|
+
agentUserIds: {},
|
|
137
|
+
resolveRoom: async () => '!r:zooid.zoon.eco',
|
|
138
|
+
ensureBot: async () => {},
|
|
139
|
+
sendMessage,
|
|
140
|
+
})
|
|
141
|
+
const c = sendMessage.mock.calls[0]![0].content
|
|
142
|
+
expect(c.body).toBe('@cloud.product:zooid.zoon.eco Triage "<img src=x> & more".')
|
|
143
|
+
expect(c.formatted_body).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -12,7 +12,7 @@ const deps = (over: Record<string, unknown> = {}) => ({
|
|
|
12
12
|
name: 'image-currency',
|
|
13
13
|
as: '@cron:example.org',
|
|
14
14
|
message,
|
|
15
|
-
|
|
15
|
+
agentUserIds: { architect: '@architect:example.org' },
|
|
16
16
|
resolveRoom: vi.fn(async () => '!room:example.org'),
|
|
17
17
|
ensureBot: vi.fn(async () => {}),
|
|
18
18
|
sendMessage: vi.fn(async () => ({ event_id: '$1' })),
|
|
@@ -20,14 +20,14 @@ const deps = (over: Record<string, unknown> = {}) => ({
|
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
describe('fireTrigger', () => {
|
|
23
|
-
it('posts text
|
|
23
|
+
it('posts text as the trigger bot user, led by a visible mention', async () => {
|
|
24
24
|
const d = deps()
|
|
25
25
|
await fireTrigger(d as never)
|
|
26
26
|
expect(d.sendMessage).toHaveBeenCalledTimes(1)
|
|
27
27
|
const call = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
|
28
28
|
expect(call.roomId).toBe('!room:example.org')
|
|
29
29
|
expect(call.asUserId).toBe('@cron:example.org')
|
|
30
|
-
expect(call.content.body).toBe('Check the pinned agent CLI versions.')
|
|
30
|
+
expect(call.content.body).toBe('@architect:example.org Check the pinned agent CLI versions.')
|
|
31
31
|
expect(call.content.msgtype).toBe('m.text')
|
|
32
32
|
})
|
|
33
33
|
|
|
@@ -36,7 +36,6 @@ describe('fireTrigger', () => {
|
|
|
36
36
|
await fireTrigger(d as never)
|
|
37
37
|
const content = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content
|
|
38
38
|
expect(content['m.mentions']).toEqual({ user_ids: ['@architect:example.org'] })
|
|
39
|
-
expect(content.body).not.toContain('@architect')
|
|
40
39
|
})
|
|
41
40
|
|
|
42
41
|
it('posts at the top level, never into a thread — runTurn makes each firing a new root', async () => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { TriggerMessage } from '@zooid/core'
|
|
2
|
+
import { buildMentionContent } from '@zooid/transport-matrix'
|
|
2
3
|
|
|
3
4
|
export interface FireTriggerDeps {
|
|
4
5
|
name: string
|
|
5
6
|
as: string
|
|
6
7
|
message: TriggerMessage
|
|
7
|
-
|
|
8
|
+
/** Local agent name → MXID. A `mention:` that is already an MXID bypasses this. */
|
|
9
|
+
agentUserIds: Record<string, string>
|
|
8
10
|
resolveRoom: (room: string) => Promise<string | null>
|
|
9
11
|
ensureBot: (asUserId: string, roomId: string) => Promise<void>
|
|
10
12
|
sendMessage: (input: {
|
|
@@ -12,11 +14,29 @@ export interface FireTriggerDeps {
|
|
|
12
14
|
asUserId: string
|
|
13
15
|
content: { msgtype: string; body: string; [k: string]: unknown }
|
|
14
16
|
}) => Promise<{ event_id: string }>
|
|
17
|
+
/**
|
|
18
|
+
* Optional: enables the [[ZOD081]] §Design 6 "nobody is listening" warning.
|
|
19
|
+
* Omitted in tests that don't care about it — the check is then skipped
|
|
20
|
+
* silently, never a hard requirement to fire.
|
|
21
|
+
*/
|
|
22
|
+
getJoinedMembers?: (roomId: string, asUserId: string) => Promise<{ joined: Record<string, unknown> }>
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
// Module-level so a daily trigger against an off laptop logs once per daemon
|
|
26
|
+
// run, not once a day. Keyed by trigger name + target so distinct triggers
|
|
27
|
+
// (or targets) each get their own first warning.
|
|
28
|
+
const warnedTargets = new Set<string>()
|
|
29
|
+
|
|
17
30
|
export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
|
|
18
|
-
const { name, as, message,
|
|
31
|
+
const { name, as, message, agentUserIds, resolveRoom, ensureBot, sendMessage, getJoinedMembers } = deps
|
|
19
32
|
try {
|
|
33
|
+
const target = message.mention.startsWith('@')
|
|
34
|
+
? message.mention
|
|
35
|
+
: agentUserIds[message.mention]
|
|
36
|
+
if (!target) {
|
|
37
|
+
console.warn(`[trigger:${name}] unknown agent "${message.mention}" — skipping`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
20
40
|
const roomId = await resolveRoom(message.room)
|
|
21
41
|
if (!roomId) {
|
|
22
42
|
console.warn(`[trigger:${name}] cannot resolve room ${message.room} — skipping`)
|
|
@@ -27,13 +47,31 @@ export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
|
|
|
27
47
|
roomId,
|
|
28
48
|
asUserId: as,
|
|
29
49
|
content: {
|
|
30
|
-
msgtype: 'm.text',
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
...buildMentionContent({ userId: target, text: message.text, msgtype: 'm.text' }),
|
|
51
|
+
'dev.zooid.trigger': {
|
|
52
|
+
name,
|
|
53
|
+
fired_at: Date.now(),
|
|
54
|
+
...(message.ttlMs !== undefined ? { ttl_ms: message.ttlMs } : {}),
|
|
55
|
+
},
|
|
35
56
|
},
|
|
36
57
|
})
|
|
58
|
+
|
|
59
|
+
// Post first, warn second: an absent target isn't a lost message —
|
|
60
|
+
// catch-up may still deliver it if that agent joins before it expires.
|
|
61
|
+
// Membership, not registration: router.ts routes only to agents joined
|
|
62
|
+
// to the room, so an agent that exists but isn't in the room misses the
|
|
63
|
+
// firing exactly as an unregistered one does.
|
|
64
|
+
if (getJoinedMembers) {
|
|
65
|
+
const { joined } = await getJoinedMembers(roomId, as)
|
|
66
|
+
const key = `${name}|${target}`
|
|
67
|
+
if (!(target in joined) && !warnedTargets.has(key)) {
|
|
68
|
+
warnedTargets.add(key)
|
|
69
|
+
console.warn(
|
|
70
|
+
`[trigger:${name}] ${target} is not in ${message.room} — nothing will handle this ` +
|
|
71
|
+
`unless that agent joins before the message expires`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
37
75
|
} catch (err) {
|
|
38
76
|
// Never throw: one bad firing must not take down the scheduler.
|
|
39
77
|
console.warn(`[trigger:${name}] failed:`, (err as Error).message)
|
|
@@ -8,6 +8,7 @@ export interface StartTriggerSchedulerDeps {
|
|
|
8
8
|
resolveRoom: FireTriggerDeps['resolveRoom']
|
|
9
9
|
ensureBot: FireTriggerDeps['ensureBot']
|
|
10
10
|
sendMessage: FireTriggerDeps['sendMessage']
|
|
11
|
+
getJoinedMembers?: FireTriggerDeps['getJoinedMembers']
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface TriggerSchedulerHandle {
|
|
@@ -29,19 +30,23 @@ export function validateCron(name: string, expr: string): void {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function startTriggerScheduler(deps: StartTriggerSchedulerDeps): TriggerSchedulerHandle {
|
|
32
|
-
const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage } = deps
|
|
33
|
+
const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage, getJoinedMembers } = deps
|
|
33
34
|
const jobs: Cron[] = []
|
|
34
35
|
|
|
35
36
|
for (const [name, trigger] of Object.entries(triggers)) {
|
|
36
37
|
if (!trigger.schedule) continue
|
|
37
38
|
const job = new Cron(trigger.schedule, () => {
|
|
38
39
|
for (const message of trigger.messages) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
void fireTrigger({
|
|
41
|
+
name,
|
|
42
|
+
as: trigger.as,
|
|
43
|
+
message,
|
|
44
|
+
agentUserIds,
|
|
45
|
+
resolveRoom,
|
|
46
|
+
ensureBot,
|
|
47
|
+
sendMessage,
|
|
48
|
+
getJoinedMembers,
|
|
49
|
+
})
|
|
45
50
|
}
|
|
46
51
|
})
|
|
47
52
|
jobs.push(job)
|
|
@@ -2,7 +2,7 @@ import { createHmac } from 'node:crypto'
|
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { Hono } from 'hono'
|
|
4
4
|
import { loadZooidConfig } from '@zooid/core'
|
|
5
|
-
import { mountWebhookRoutes } from './webhook-routes.js'
|
|
5
|
+
import { eventNameFor, mountWebhookRoutes } from './webhook-routes.js'
|
|
6
6
|
|
|
7
7
|
const secret = 'route-secret'
|
|
8
8
|
const body = JSON.stringify({ action: 'opened' })
|
|
@@ -72,3 +72,33 @@ describe('webhook route contract', () => {
|
|
|
72
72
|
expect(sent).toHaveLength(0)
|
|
73
73
|
})
|
|
74
74
|
})
|
|
75
|
+
|
|
76
|
+
describe('event name binding', () => {
|
|
77
|
+
const headers = { 'x-github-event': 'pull_request' }
|
|
78
|
+
|
|
79
|
+
it('reads GitHub from the header, not the payload', () => {
|
|
80
|
+
expect(eventNameFor('github', headers, { type: 'ignored' })).toBe('pull_request')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('reads stripe and standard-webhooks from body.type', () => {
|
|
84
|
+
expect(eventNameFor('stripe', {}, { type: 'invoice.payment_failed' })).toBe(
|
|
85
|
+
'invoice.payment_failed',
|
|
86
|
+
)
|
|
87
|
+
expect(eventNameFor('standard', {}, { type: 'user.created' })).toBe('user.created')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('reads slack from body.event.type', () => {
|
|
91
|
+
expect(eventNameFor('slack', {}, { event: { type: 'app_mention' } })).toBe('app_mention')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('leaves custom unbound — only the operator knows their service shape', () => {
|
|
95
|
+
expect(eventNameFor('custom', headers, { type: 'anything' })).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('yields undefined rather than throwing on a missing or wrong-typed path', () => {
|
|
99
|
+
expect(eventNameFor('stripe', {}, undefined)).toBeUndefined()
|
|
100
|
+
expect(eventNameFor('stripe', {}, { type: 42 })).toBeUndefined()
|
|
101
|
+
expect(eventNameFor('slack', {}, { event: null })).toBeUndefined()
|
|
102
|
+
expect(eventNameFor('github', {}, {})).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -16,8 +16,42 @@ const TRUNCATION_MARKER = '\n\n… (truncated)'
|
|
|
16
16
|
// Comfortably past any provider's redelivery window.
|
|
17
17
|
const DELIVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Where each provider puts its event name. GitHub is the only one that uses
|
|
21
|
+
* a header; the rest carry it in the payload. Reading it per provider is the
|
|
22
|
+
* point — it keeps provider knowledge in code (the same knowledge the
|
|
23
|
+
* signature table encodes) so `event` means the same thing in an operator's
|
|
24
|
+
* `match:` whatever the sender. `custom` is absent on purpose: only the
|
|
25
|
+
* operator knows their service's shape, so their predicate reads `body`.
|
|
26
|
+
*/
|
|
27
|
+
const EVENT_SOURCE_BY_PROVIDER: Partial<
|
|
28
|
+
Record<WebhookTriggerConfig['provider'], { header: string } | { path: readonly string[] }>
|
|
29
|
+
> = {
|
|
30
|
+
github: { header: 'x-github-event' },
|
|
31
|
+
stripe: { path: ['type'] },
|
|
32
|
+
standard: { path: ['type'] },
|
|
33
|
+
slack: { path: ['event', 'type'] },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Follow a dotted path through a parsed payload, yielding a string or nothing. */
|
|
37
|
+
function stringAt(body: unknown, path: readonly string[]): string | undefined {
|
|
38
|
+
let cursor: unknown = body
|
|
39
|
+
for (const key of path) {
|
|
40
|
+
if (typeof cursor !== 'object' || cursor === null) return undefined
|
|
41
|
+
cursor = (cursor as Record<string, unknown>)[key]
|
|
42
|
+
}
|
|
43
|
+
return typeof cursor === 'string' ? cursor : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `event` binding for `match:` and `${...}`, per provider. */
|
|
47
|
+
export function eventNameFor(
|
|
48
|
+
provider: WebhookTriggerConfig['provider'],
|
|
49
|
+
headers: Record<string, string | undefined>,
|
|
50
|
+
body: unknown,
|
|
51
|
+
): string | undefined {
|
|
52
|
+
const source = EVENT_SOURCE_BY_PROVIDER[provider]
|
|
53
|
+
if (!source) return undefined
|
|
54
|
+
return 'header' in source ? headers[source.header] : stringAt(body, source.path)
|
|
21
55
|
}
|
|
22
56
|
|
|
23
57
|
const DELIVERY_ID_HEADER_BY_PROVIDER: Partial<Record<WebhookTriggerConfig['provider'], string>> = {
|
|
@@ -50,6 +84,7 @@ export interface WebhookDeps {
|
|
|
50
84
|
resolveRoom: FireTriggerDeps['resolveRoom']
|
|
51
85
|
ensureBot: FireTriggerDeps['ensureBot']
|
|
52
86
|
sendMessage: FireTriggerDeps['sendMessage']
|
|
87
|
+
getJoinedMembers?: FireTriggerDeps['getJoinedMembers']
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
/**
|
|
@@ -111,9 +146,6 @@ async function handleDelivery(
|
|
|
111
146
|
const deliveryId = customDeliveryId ?? (idHeader ? headers[idHeader] : undefined)
|
|
112
147
|
if (deliveryId !== undefined && cache.seen(`${name}:${deliveryId}`)) return
|
|
113
148
|
|
|
114
|
-
const eventHeader = EVENT_HEADER_BY_PROVIDER[webhook.provider]
|
|
115
|
-
const event = eventHeader ? headers[eventHeader] : undefined
|
|
116
|
-
|
|
117
149
|
let body: unknown
|
|
118
150
|
try {
|
|
119
151
|
body = JSON.parse(raw)
|
|
@@ -121,6 +153,10 @@ async function handleDelivery(
|
|
|
121
153
|
body = undefined
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
// After parsing: every provider but GitHub carries its event name in the
|
|
157
|
+
// payload, not a header.
|
|
158
|
+
const event = eventNameFor(webhook.provider, headers, body)
|
|
159
|
+
|
|
124
160
|
const ctx: MatchContext = {
|
|
125
161
|
event,
|
|
126
162
|
body,
|
|
@@ -131,20 +167,15 @@ async function handleDelivery(
|
|
|
131
167
|
for (const message of trigger.messages) {
|
|
132
168
|
if (message.match !== undefined && !evaluateMatch(message.match, ctx)) continue
|
|
133
169
|
|
|
134
|
-
const agentUserId = deps.agentUserIds[message.mention]
|
|
135
|
-
if (!agentUserId) {
|
|
136
|
-
console.warn(`[webhook:${name}] unknown agent "${message.mention}" — skipping`)
|
|
137
|
-
continue
|
|
138
|
-
}
|
|
139
|
-
|
|
140
170
|
await fireTrigger({
|
|
141
171
|
name,
|
|
142
172
|
as: trigger.as,
|
|
143
173
|
message: { ...message, text: renderTemplate(message.text, ctx) },
|
|
144
|
-
|
|
174
|
+
agentUserIds: deps.agentUserIds,
|
|
145
175
|
resolveRoom: deps.resolveRoom,
|
|
146
176
|
ensureBot: deps.ensureBot,
|
|
147
177
|
sendMessage: deps.sendMessage,
|
|
178
|
+
getJoinedMembers: deps.getJoinedMembers,
|
|
148
179
|
})
|
|
149
180
|
}
|
|
150
181
|
} catch (err) {
|