dsh-agora-plugin 0.6.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 +520 -0
- package/client/index.js +492 -0
- package/cordis.patch.yml +14 -0
- package/dsh.plugin.json +7 -0
- package/lib/agora-client.d.ts +73 -0
- package/lib/agora-client.d.ts.map +1 -0
- package/lib/agora-client.js +207 -0
- package/lib/agora-client.js.map +1 -0
- package/lib/client.js +492 -0
- package/lib/command-adapter.d.ts +44 -0
- package/lib/command-adapter.d.ts.map +1 -0
- package/lib/command-adapter.js +70 -0
- package/lib/command-adapter.js.map +1 -0
- package/lib/command.d.ts +53 -0
- package/lib/command.d.ts.map +1 -0
- package/lib/command.js +375 -0
- package/lib/command.js.map +1 -0
- package/lib/context-types.d.ts +51 -0
- package/lib/context-types.d.ts.map +1 -0
- package/lib/context-types.js +2 -0
- package/lib/context-types.js.map +1 -0
- package/lib/contracts.d.ts +402 -0
- package/lib/contracts.d.ts.map +1 -0
- package/lib/contracts.js +2 -0
- package/lib/contracts.js.map +1 -0
- package/lib/extension-sdk.d.ts +74 -0
- package/lib/extension-sdk.d.ts.map +1 -0
- package/lib/extension-sdk.js +160 -0
- package/lib/extension-sdk.js.map +1 -0
- package/lib/harness-runtime.d.ts +29 -0
- package/lib/harness-runtime.d.ts.map +1 -0
- package/lib/harness-runtime.js +422 -0
- package/lib/harness-runtime.js.map +1 -0
- package/lib/http-api.d.ts +10 -0
- package/lib/http-api.d.ts.map +1 -0
- package/lib/http-api.js +282 -0
- package/lib/http-api.js.map +1 -0
- package/lib/im-bridge-v1.d.ts +37 -0
- package/lib/im-bridge-v1.d.ts.map +1 -0
- package/lib/im-bridge-v1.js +43 -0
- package/lib/im-bridge-v1.js.map +1 -0
- package/lib/im-gateway.d.ts +24 -0
- package/lib/im-gateway.d.ts.map +1 -0
- package/lib/im-gateway.js +53 -0
- package/lib/im-gateway.js.map +1 -0
- package/lib/index.d.ts +46 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +210 -0
- package/lib/index.js.map +1 -0
- package/lib/node-worker.d.ts +52 -0
- package/lib/node-worker.d.ts.map +1 -0
- package/lib/node-worker.js +372 -0
- package/lib/node-worker.js.map +1 -0
- package/lib/service.d.ts +51 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +196 -0
- package/lib/service.js.map +1 -0
- package/lib/tool.d.ts +72 -0
- package/lib/tool.d.ts.map +1 -0
- package/lib/tool.js +206 -0
- package/lib/tool.js.map +1 -0
- package/package.json +74 -0
- package/patches/dsh-im/@xmanrui__dsh-im@2.1.0.patch +920 -0
- package/patches/dsh-im/@xmanrui__dsh-im@2.3.0.patch +984 -0
- package/patches/dsh-im/README.md +82 -0
- package/src/agora-client.ts +333 -0
- package/src/command-adapter.ts +106 -0
- package/src/command.ts +424 -0
- package/src/context-types.ts +54 -0
- package/src/contracts.ts +413 -0
- package/src/extension-sdk.ts +225 -0
- package/src/harness-runtime.ts +518 -0
- package/src/http-api.ts +269 -0
- package/src/im-bridge-v1.ts +73 -0
- package/src/im-gateway.ts +82 -0
- package/src/index.ts +260 -0
- package/src/node-worker.ts +442 -0
- package/src/service.ts +262 -0
- package/src/tool.ts +269 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Versioned dsh-im Agora bridge patches
|
|
2
|
+
|
|
3
|
+
This directory contains reviewed pnpm patches for exact versions of the third-party `@xmanrui/dsh-im` package.
|
|
4
|
+
|
|
5
|
+
| Package | Patch |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `@xmanrui/dsh-im@2.1.0` | `@xmanrui__dsh-im@2.1.0.patch` |
|
|
8
|
+
| `@xmanrui/dsh-im@2.3.0` | `@xmanrui__dsh-im@2.3.0.patch` |
|
|
9
|
+
|
|
10
|
+
Each patch adds the public, token-free `dsh-im.bridge/v1` Cordis service used by `dsh-agora`:
|
|
11
|
+
|
|
12
|
+
- safe Bot identity, connectivity, and capability discovery;
|
|
13
|
+
- DSH Session to IM conversation/thread route observation;
|
|
14
|
+
- idempotent proactive delivery through a selected Bot;
|
|
15
|
+
- Discord outbound delivery without accepting Bot-authored inbound messages.
|
|
16
|
+
|
|
17
|
+
The patches include a rebuilt upstream `lib/index.js`, so a target host does not need dsh-im development dependencies.
|
|
18
|
+
|
|
19
|
+
## Version rule
|
|
20
|
+
|
|
21
|
+
Apply a patch only to the exact package version in its filename. A dsh-im upgrade requires regenerating, reviewing, and testing a new patch. pnpm intentionally rejects a version mismatch.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Pin dsh-im first, for example:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
dsh plugin --profile web add @xmanrui/dsh-im@2.3.0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Copy the matching patch to the DSH profile:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
PROFILE="${DSH_HOME:-$HOME/.dsh}/profiles/web"
|
|
35
|
+
mkdir -p "$PROFILE/patches"
|
|
36
|
+
cp @xmanrui__dsh-im@2.3.0.patch "$PROFILE/patches/"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Merge the matching declaration into `$PROFILE/pnpm-workspace.yaml` without removing existing `allowBuilds` or other patches:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
patchedDependencies:
|
|
43
|
+
'@xmanrui/dsh-im@2.3.0': patches/@xmanrui__dsh-im@2.3.0.patch
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Apply it and make the lockfile reproducible:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm --dir "$PROFILE" install
|
|
50
|
+
pnpm --dir "$PROFILE" install --frozen-lockfile
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Restart DSH, then verify the local snapshot:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl -fsS -X POST http://127.0.0.1:3080/dsh-agora/api/snapshot \
|
|
57
|
+
-H 'content-type: application/json' \
|
|
58
|
+
-d '{}'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Expected bridge state:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
imBridge.state: connected
|
|
65
|
+
imBridge.protocol: dsh-im.bridge/v1
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`dsh-im.command-gateway/v1` is a separate optional interface and is not added by these patches. Seeing `im.state: unavailable` together with a connected bridge is expected.
|
|
69
|
+
|
|
70
|
+
## Rebuilding a patch for a new dsh-im version
|
|
71
|
+
|
|
72
|
+
Use pnpm's patch workflow in an isolated profile or test project:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pnpm patch @xmanrui/dsh-im@<version>
|
|
76
|
+
# edit and rebuild the extracted package
|
|
77
|
+
pnpm patch-commit <path-printed-by-pnpm>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Review the resulting source and bundled diff, run the dsh-im build/package checks, apply it to a clean exact-version install, and run a real bridge smoke test before adding the artifact here.
|
|
81
|
+
|
|
82
|
+
Do not disable pnpm's build-script policy globally. If a reviewed dependency needs an install script, add only the exact package key under `allowBuilds`.
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgoraHealth,
|
|
3
|
+
AgoraTask,
|
|
4
|
+
AgoraTaskStatus,
|
|
5
|
+
AgentScorecard,
|
|
6
|
+
CoordinationRun,
|
|
7
|
+
CoordinationRunStatus,
|
|
8
|
+
CreateCoordinationRunInput,
|
|
9
|
+
CreateAgoraTaskInput,
|
|
10
|
+
CreateRuntimeDispatchInput,
|
|
11
|
+
RuntimeDispatch,
|
|
12
|
+
RecordRuntimeDispatchProgressInput,
|
|
13
|
+
RuntimeDispatchProgress,
|
|
14
|
+
RuntimeResultEnvelope,
|
|
15
|
+
RuntimeDelivery,
|
|
16
|
+
RuntimeNode,
|
|
17
|
+
RuntimeNodeHeartbeatInput,
|
|
18
|
+
RuntimeSessionBinding,
|
|
19
|
+
RuntimeTarget,
|
|
20
|
+
} from './contracts.js'
|
|
21
|
+
|
|
22
|
+
export interface AgoraClientOptions {
|
|
23
|
+
readonly serverUrl: string
|
|
24
|
+
readonly apiToken?: string | undefined
|
|
25
|
+
readonly timeoutMs?: number | undefined
|
|
26
|
+
readonly fetch?: typeof globalThis.fetch | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class AgoraApiError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
readonly status: number,
|
|
33
|
+
readonly path: string,
|
|
34
|
+
readonly detail: unknown,
|
|
35
|
+
) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'AgoraApiError'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AgoraClient {
|
|
42
|
+
readonly serverUrl: string
|
|
43
|
+
private readonly apiToken: string | undefined
|
|
44
|
+
private readonly timeoutMs: number
|
|
45
|
+
private readonly fetchImpl: typeof globalThis.fetch
|
|
46
|
+
|
|
47
|
+
constructor(options: AgoraClientOptions) {
|
|
48
|
+
const url = new URL(options.serverUrl)
|
|
49
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
50
|
+
throw new TypeError('Agora serverUrl must use http or https')
|
|
51
|
+
}
|
|
52
|
+
this.serverUrl = url.toString().replace(/\/$/u, '')
|
|
53
|
+
this.apiToken = nonEmpty(options.apiToken)
|
|
54
|
+
this.timeoutMs = normalizeTimeout(options.timeoutMs)
|
|
55
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
health(signal?: AbortSignal): Promise<AgoraHealth> {
|
|
59
|
+
return this.request('/api/health', { signal })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
listTasks(state?: string, projectId?: string, signal?: AbortSignal): Promise<AgoraTask[]> {
|
|
63
|
+
const query = new URLSearchParams()
|
|
64
|
+
if (nonEmpty(state)) query.set('state', state!.trim())
|
|
65
|
+
if (nonEmpty(projectId)) query.set('project_id', projectId!.trim())
|
|
66
|
+
const suffix = query.size === 0 ? '' : `?${query.toString()}`
|
|
67
|
+
return this.request(`/api/tasks${suffix}`, { signal })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getTask(taskId: string, signal?: AbortSignal): Promise<AgoraTask> {
|
|
71
|
+
return this.request(`/api/tasks/${encodeURIComponent(requireValue(taskId, 'task id'))}`, { signal })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
taskStatus(taskId: string, signal?: AbortSignal): Promise<AgoraTaskStatus> {
|
|
75
|
+
return this.request(`/api/tasks/${encodeURIComponent(requireValue(taskId, 'task id'))}/status`, { signal })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
createTask(input: CreateAgoraTaskInput, signal?: AbortSignal): Promise<AgoraTask> {
|
|
79
|
+
const title = requireValue(input.title, 'task title')
|
|
80
|
+
const body = {
|
|
81
|
+
title,
|
|
82
|
+
type: nonEmpty(input.type) ?? 'general',
|
|
83
|
+
creator: nonEmpty(input.creator) ?? 'dsh',
|
|
84
|
+
description: input.description ?? '',
|
|
85
|
+
priority: input.priority ?? 'normal',
|
|
86
|
+
locale: input.locale ?? 'zh-CN',
|
|
87
|
+
...(nonEmpty(input.projectId) ? { project_id: input.projectId!.trim() } : {}),
|
|
88
|
+
...(input.imTarget === undefined ? {} : { im_target: compactImTarget(input.imTarget) }),
|
|
89
|
+
}
|
|
90
|
+
return this.request('/api/tasks', { method: 'POST', body, signal })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
heartbeatRuntimeNode(nodeId: string, input: RuntimeNodeHeartbeatInput, signal?: AbortSignal): Promise<RuntimeNode> {
|
|
94
|
+
return this.request(`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/heartbeat`, {
|
|
95
|
+
method: 'PUT', body: input, signal,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
listRuntimeNodes(signal?: AbortSignal): Promise<RuntimeNode[]> {
|
|
100
|
+
return this.request<{ nodes: RuntimeNode[] }>('/api/runtime-nodes', { signal }).then(value => value.nodes)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
listRuntimeTargets(signal?: AbortSignal): Promise<RuntimeTarget[]> {
|
|
104
|
+
return this.request<{ runtime_targets: RuntimeTarget[] }>('/api/runtime-targets', { signal }).then(value => value.runtime_targets)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
createRuntimeDispatch(nodeId: string, input: CreateRuntimeDispatchInput, signal?: AbortSignal): Promise<RuntimeDispatch> {
|
|
108
|
+
return this.request(`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches`, {
|
|
109
|
+
method: 'POST', body: input, signal,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getRuntimeDispatch(dispatchId: string, signal?: AbortSignal): Promise<RuntimeDispatch> {
|
|
114
|
+
return this.request(`/api/runtime-dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}`, { signal })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
listRuntimeDispatchProgress(dispatchId: string, signal?: AbortSignal): Promise<RuntimeDispatchProgress[]> {
|
|
118
|
+
return this.request<{ events: RuntimeDispatchProgress[] }>(
|
|
119
|
+
`/api/runtime-dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/progress`,
|
|
120
|
+
{ signal },
|
|
121
|
+
).then(value => value.events)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
createCoordinationRun(input: CreateCoordinationRunInput, signal?: AbortSignal): Promise<CoordinationRun> {
|
|
125
|
+
return this.request('/api/coordination-runs', { method: 'POST', body: input, signal })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getCoordinationRun(runId: string, signal?: AbortSignal): Promise<CoordinationRun> {
|
|
129
|
+
return this.request(`/api/coordination-runs/${encodeURIComponent(requireValue(runId, 'coordination run id'))}`, { signal })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
listCoordinationRuns(status?: CoordinationRunStatus, signal?: AbortSignal): Promise<CoordinationRun[]> {
|
|
133
|
+
const suffix = status ? `?status=${encodeURIComponent(status)}` : ''
|
|
134
|
+
return this.request<{ runs: CoordinationRun[] }>(`/api/coordination-runs${suffix}`, { signal }).then(value => value.runs)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
listAgentScorecards(taskType?: string, signal?: AbortSignal): Promise<AgentScorecard[]> {
|
|
138
|
+
const suffix = nonEmpty(taskType) ? `?task_type=${encodeURIComponent(taskType!.trim())}` : ''
|
|
139
|
+
return this.request<{ scorecards: AgentScorecard[] }>(`/api/agent-scorecards${suffix}`, { signal }).then(value => value.scorecards)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
claimRuntimeDispatch(nodeId: string, instanceId: string, leaseSeconds: number, signal?: AbortSignal): Promise<RuntimeDispatch | null> {
|
|
143
|
+
return this.request<{ dispatch: RuntimeDispatch | null }>(
|
|
144
|
+
`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/claim`,
|
|
145
|
+
{ method: 'POST', body: { instance_id: instanceId, lease_seconds: leaseSeconds }, signal },
|
|
146
|
+
).then(value => value.dispatch)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
renewRuntimeDispatch(
|
|
150
|
+
nodeId: string,
|
|
151
|
+
dispatchId: string,
|
|
152
|
+
instanceId: string,
|
|
153
|
+
claimToken: string,
|
|
154
|
+
leaseSeconds: number,
|
|
155
|
+
signal?: AbortSignal,
|
|
156
|
+
): Promise<RuntimeDispatch> {
|
|
157
|
+
return this.request(
|
|
158
|
+
`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/renew`,
|
|
159
|
+
{
|
|
160
|
+
method: 'POST',
|
|
161
|
+
body: {
|
|
162
|
+
instance_id: requireValue(instanceId, 'instance id'),
|
|
163
|
+
claim_token: requireValue(claimToken, 'claim token'),
|
|
164
|
+
lease_seconds: leaseSeconds,
|
|
165
|
+
},
|
|
166
|
+
signal,
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
recordRuntimeDispatchProgress(
|
|
172
|
+
nodeId: string,
|
|
173
|
+
dispatchId: string,
|
|
174
|
+
input: RecordRuntimeDispatchProgressInput,
|
|
175
|
+
signal?: AbortSignal,
|
|
176
|
+
): Promise<RuntimeDispatchProgress> {
|
|
177
|
+
return this.request(
|
|
178
|
+
`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/progress`,
|
|
179
|
+
{ method: 'POST', body: input, signal },
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
completeRuntimeDispatch(
|
|
184
|
+
nodeId: string,
|
|
185
|
+
dispatchId: string,
|
|
186
|
+
input: {
|
|
187
|
+
readonly instance_id: string
|
|
188
|
+
readonly claim_token: string
|
|
189
|
+
readonly status: 'completed' | 'failed'
|
|
190
|
+
readonly session_id?: string | null
|
|
191
|
+
readonly result?: Readonly<Record<string, unknown>> | null
|
|
192
|
+
readonly result_envelope?: RuntimeResultEnvelope | null
|
|
193
|
+
readonly error?: string | null
|
|
194
|
+
readonly delivery_payload?: Readonly<Record<string, unknown>> | null
|
|
195
|
+
},
|
|
196
|
+
signal?: AbortSignal,
|
|
197
|
+
): Promise<RuntimeDispatch> {
|
|
198
|
+
return this.request(
|
|
199
|
+
`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/dispatches/${encodeURIComponent(requireValue(dispatchId, 'dispatch id'))}/complete`,
|
|
200
|
+
{ method: 'POST', body: input, signal },
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
claimRuntimeDelivery(
|
|
205
|
+
nodeId: string,
|
|
206
|
+
instanceId: string,
|
|
207
|
+
leaseSeconds: number,
|
|
208
|
+
signal?: AbortSignal,
|
|
209
|
+
): Promise<RuntimeDelivery | null> {
|
|
210
|
+
return this.request<{ delivery: RuntimeDelivery | null }>(
|
|
211
|
+
`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/deliveries/claim`,
|
|
212
|
+
{ method: 'POST', body: { instance_id: instanceId, lease_seconds: leaseSeconds }, signal },
|
|
213
|
+
).then(value => value.delivery)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
completeRuntimeDelivery(
|
|
217
|
+
nodeId: string,
|
|
218
|
+
deliveryId: string,
|
|
219
|
+
input:
|
|
220
|
+
| { readonly instance_id: string; readonly claim_token: string; readonly status: 'delivered'; readonly receipt?: Readonly<Record<string, unknown>> | null }
|
|
221
|
+
| { readonly instance_id: string; readonly claim_token: string; readonly status: 'retry'; readonly error: string; readonly retry_delay_seconds: number }
|
|
222
|
+
| { readonly instance_id: string; readonly claim_token: string; readonly status: 'failed'; readonly error: string },
|
|
223
|
+
signal?: AbortSignal,
|
|
224
|
+
): Promise<RuntimeDelivery> {
|
|
225
|
+
return this.request(
|
|
226
|
+
`/api/runtime-nodes/${encodeURIComponent(requireValue(nodeId, 'node id'))}/deliveries/${encodeURIComponent(requireValue(deliveryId, 'delivery id'))}/complete`,
|
|
227
|
+
{ method: 'POST', body: input, signal },
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
bindRuntimeSession(
|
|
232
|
+
taskId: string,
|
|
233
|
+
participantBindingId: string,
|
|
234
|
+
sessionId: string,
|
|
235
|
+
agentRef?: string,
|
|
236
|
+
signal?: AbortSignal,
|
|
237
|
+
): Promise<RuntimeSessionBinding> {
|
|
238
|
+
return this.request(
|
|
239
|
+
`/api/tasks/${encodeURIComponent(requireValue(taskId, 'task id'))}/runtime-session-bindings/${encodeURIComponent(requireValue(participantBindingId, 'participant binding id'))}`,
|
|
240
|
+
{
|
|
241
|
+
method: 'PUT',
|
|
242
|
+
body: {
|
|
243
|
+
runtime_provider: 'dsh',
|
|
244
|
+
runtime_session_ref: requireValue(sessionId, 'session id'),
|
|
245
|
+
runtime_actor_ref: nonEmpty(agentRef) ?? null,
|
|
246
|
+
continuity_ref: `dsh:${requireValue(sessionId, 'session id')}`,
|
|
247
|
+
presence_state: 'active',
|
|
248
|
+
binding_reason: 'runtime_node_dispatch',
|
|
249
|
+
},
|
|
250
|
+
signal,
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async request<T>(
|
|
256
|
+
path: string,
|
|
257
|
+
options: {
|
|
258
|
+
readonly method?: string | undefined
|
|
259
|
+
readonly body?: unknown
|
|
260
|
+
readonly signal?: AbortSignal | undefined
|
|
261
|
+
} = {},
|
|
262
|
+
): Promise<T> {
|
|
263
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs)
|
|
264
|
+
const signal = options.signal === undefined
|
|
265
|
+
? timeoutSignal
|
|
266
|
+
: AbortSignal.any([options.signal, timeoutSignal])
|
|
267
|
+
const headers: Record<string, string> = { Accept: 'application/json' }
|
|
268
|
+
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
|
269
|
+
if (this.apiToken !== undefined) headers.Authorization = `Bearer ${this.apiToken}`
|
|
270
|
+
|
|
271
|
+
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
|
272
|
+
method: options.method ?? 'GET',
|
|
273
|
+
headers,
|
|
274
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
275
|
+
signal,
|
|
276
|
+
})
|
|
277
|
+
const text = await response.text()
|
|
278
|
+
const payload = parsePayload(text)
|
|
279
|
+
if (!response.ok) {
|
|
280
|
+
const detail = errorDetail(payload, text)
|
|
281
|
+
throw new AgoraApiError(`Agora API ${response.status} at ${path}: ${detail}`, response.status, path, payload)
|
|
282
|
+
}
|
|
283
|
+
return payload as T
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function compactImTarget(target: NonNullable<CreateAgoraTaskInput['imTarget']>): Record<string, unknown> {
|
|
288
|
+
return {
|
|
289
|
+
...(nonEmpty(target.provider) ? { provider: target.provider!.trim() } : {}),
|
|
290
|
+
...(nonEmpty(target.conversation_ref) ? { conversation_ref: target.conversation_ref!.trim() } : {}),
|
|
291
|
+
...(nonEmpty(target.thread_ref) ? { thread_ref: target.thread_ref!.trim() } : {}),
|
|
292
|
+
...(target.visibility === undefined ? {} : { visibility: target.visibility }),
|
|
293
|
+
...(target.participant_refs === undefined ? {} : { participant_refs: [...target.participant_refs] }),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeTimeout(value: number | undefined): number {
|
|
298
|
+
const timeout = value ?? 10_000
|
|
299
|
+
if (!Number.isSafeInteger(timeout) || timeout < 100 || timeout > 300_000) {
|
|
300
|
+
throw new TypeError('requestTimeoutMs must be an integer between 100 and 300000')
|
|
301
|
+
}
|
|
302
|
+
return timeout
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function requireValue(value: string, label: string): string {
|
|
306
|
+
const normalized = nonEmpty(value)
|
|
307
|
+
if (normalized === undefined) throw new TypeError(`${label} is required`)
|
|
308
|
+
return normalized
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
312
|
+
const normalized = value?.trim()
|
|
313
|
+
return normalized === '' ? undefined : normalized
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parsePayload(text: string): unknown {
|
|
317
|
+
if (text.trim() === '') return {}
|
|
318
|
+
try {
|
|
319
|
+
return JSON.parse(text) as unknown
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw new SyntaxError(`Agora returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function errorDetail(payload: unknown, fallback: string): string {
|
|
326
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
327
|
+
const record = payload as Record<string, unknown>
|
|
328
|
+
for (const key of ['detail', 'message', 'error']) {
|
|
329
|
+
if (typeof record[key] === 'string' && record[key].trim() !== '') return record[key]
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return fallback.trim() || 'request failed'
|
|
333
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { AgoraCommandResult, AgoraRequestContext } from './contracts.js'
|
|
2
|
+
import type { DshImBridgeV1 } from './im-bridge-v1.js'
|
|
3
|
+
|
|
4
|
+
export const DSH_AGORA_COMMAND_ADAPTER_PROTOCOL = 'dsh-agora.command-adapter/v1' as const
|
|
5
|
+
|
|
6
|
+
export interface DshAgoraCommandEventV1 {
|
|
7
|
+
readonly protocol: typeof DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
|
|
8
|
+
readonly idempotency_key: string
|
|
9
|
+
readonly input: string
|
|
10
|
+
readonly actor_ref: string
|
|
11
|
+
readonly provider?: string | null
|
|
12
|
+
readonly conversation_ref?: string | null
|
|
13
|
+
readonly thread_ref?: string | null
|
|
14
|
+
readonly reply?: {
|
|
15
|
+
readonly enabled: boolean
|
|
16
|
+
readonly bot_ref?: string | null
|
|
17
|
+
} | null
|
|
18
|
+
readonly metadata?: Readonly<Record<string, unknown>> | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DshAgoraCommandEventResultV1 {
|
|
22
|
+
readonly protocol: typeof DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
|
|
23
|
+
readonly idempotency_key: string
|
|
24
|
+
readonly result: AgoraCommandResult
|
|
25
|
+
readonly delivery: {
|
|
26
|
+
readonly sent: boolean
|
|
27
|
+
readonly provider_message_refs: readonly string[]
|
|
28
|
+
readonly reason?: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DshAgoraCommandAdapterV1 {
|
|
33
|
+
readonly protocol: typeof DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
|
|
34
|
+
ingest(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class DshAgoraCommandAdapter implements DshAgoraCommandAdapterV1 {
|
|
38
|
+
readonly protocol = DSH_AGORA_COMMAND_ADAPTER_PROTOCOL
|
|
39
|
+
private readonly receipts = new Map<string, Promise<DshAgoraCommandEventResultV1>>()
|
|
40
|
+
|
|
41
|
+
constructor(private readonly options: {
|
|
42
|
+
execute(input: string, context: AgoraRequestContext, signal?: AbortSignal): Promise<AgoraCommandResult>
|
|
43
|
+
bridge(): DshImBridgeV1 | null
|
|
44
|
+
receiptLimit?: number
|
|
45
|
+
}) {}
|
|
46
|
+
|
|
47
|
+
ingest(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1> {
|
|
48
|
+
validateEvent(event)
|
|
49
|
+
const existing = this.receipts.get(event.idempotency_key)
|
|
50
|
+
if (existing) return existing
|
|
51
|
+
const pending = this.execute(event, signal)
|
|
52
|
+
this.receipts.set(event.idempotency_key, pending)
|
|
53
|
+
while (this.receipts.size > (this.options.receiptLimit ?? 2_048)) this.receipts.delete(this.receipts.keys().next().value!)
|
|
54
|
+
pending.catch(() => {
|
|
55
|
+
if (this.receipts.get(event.idempotency_key) === pending) this.receipts.delete(event.idempotency_key)
|
|
56
|
+
})
|
|
57
|
+
return pending
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async execute(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1> {
|
|
61
|
+
const result = await this.options.execute(event.input, {
|
|
62
|
+
actorId: event.actor_ref,
|
|
63
|
+
...(event.provider ? { provider: event.provider } : {}),
|
|
64
|
+
...(event.conversation_ref ? { conversationRef: event.conversation_ref } : {}),
|
|
65
|
+
...(event.thread_ref ? { threadRef: event.thread_ref } : {}),
|
|
66
|
+
}, signal)
|
|
67
|
+
const bridge = this.options.bridge()
|
|
68
|
+
if (!event.reply?.enabled) return envelope(event, result, { sent: false, provider_message_refs: [], reason: 'reply disabled' })
|
|
69
|
+
if (!bridge || !event.provider || !event.conversation_ref) {
|
|
70
|
+
return envelope(event, result, { sent: false, provider_message_refs: [], reason: 'IM bridge or destination unavailable' })
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const receipt = await bridge.send({
|
|
74
|
+
provider: event.provider,
|
|
75
|
+
conversation_ref: event.conversation_ref,
|
|
76
|
+
...(event.reply.bot_ref === undefined ? {} : { bot_ref: event.reply.bot_ref }),
|
|
77
|
+
...(event.thread_ref === undefined ? {} : { thread_ref: event.thread_ref }),
|
|
78
|
+
text: result.text ?? (result.kind === 'success' ? 'Agora command completed.' : 'Agora command failed.'),
|
|
79
|
+
idempotency_key: `command:${event.idempotency_key}`,
|
|
80
|
+
})
|
|
81
|
+
return envelope(event, result, { sent: true, provider_message_refs: receipt.provider_message_refs })
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return envelope(event, result, {
|
|
84
|
+
sent: false,
|
|
85
|
+
provider_message_refs: [],
|
|
86
|
+
reason: `IM delivery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function envelope(
|
|
93
|
+
event: DshAgoraCommandEventV1,
|
|
94
|
+
result: AgoraCommandResult,
|
|
95
|
+
delivery: DshAgoraCommandEventResultV1['delivery'],
|
|
96
|
+
): DshAgoraCommandEventResultV1 {
|
|
97
|
+
return { protocol: DSH_AGORA_COMMAND_ADAPTER_PROTOCOL, idempotency_key: event.idempotency_key, result, delivery }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateEvent(event: DshAgoraCommandEventV1): void {
|
|
101
|
+
if (event.protocol !== DSH_AGORA_COMMAND_ADAPTER_PROTOCOL) throw new TypeError(`command event protocol must be ${DSH_AGORA_COMMAND_ADAPTER_PROTOCOL}`)
|
|
102
|
+
for (const [name, value] of [['idempotency_key', event.idempotency_key], ['input', event.input], ['actor_ref', event.actor_ref]] as const) {
|
|
103
|
+
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} is required`)
|
|
104
|
+
}
|
|
105
|
+
if (event.reply?.enabled && (!event.provider || !event.conversation_ref)) throw new TypeError('reply requires provider and conversation_ref')
|
|
106
|
+
}
|