@addozhang/dsh-discord 0.3.0 → 0.5.0-alpha.1
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 +13 -13
- package/README.zh.md +13 -13
- package/cordis.patch.yml +1 -1
- package/lib/client/card-controller.d.ts +1 -1
- package/lib/client/card-form.d.ts +2 -1
- package/lib/client/index.d.ts +2 -2
- package/lib/client/slot-contract.d.ts +7 -0
- package/lib/client/snapshot-store.d.ts +1 -1
- package/lib/compose.d.ts +22 -2
- package/lib/compose.js +9 -4
- package/lib/dsh/host-asks.d.ts +90 -0
- package/lib/dsh/host-asks.js +143 -0
- package/lib/dsh/host-events.d.ts +42 -0
- package/lib/dsh/host-events.js +234 -0
- package/lib/dsh/host-face.d.ts +418 -0
- package/lib/dsh/host-face.js +598 -0
- package/lib/features/adapter-status.d.ts +15 -2
- package/lib/features/adapter-status.js +100 -4
- package/lib/features/approval-store.d.ts +1 -1
- package/lib/features/image-collection.d.ts +11 -0
- package/lib/features/image-collection.js +22 -8
- package/lib/features/image-download.d.ts +6 -0
- package/lib/features/image-download.js +21 -0
- package/lib/features/interaction-router.d.ts +2 -2
- package/lib/features/model-control.d.ts +1 -1
- package/lib/features/project-list.d.ts +1 -1
- package/lib/features/project-list.js +1 -1
- package/lib/features/prompt-submission.d.ts +9 -0
- package/lib/features/prompt-submission.js +7 -1
- package/lib/features/session-mainline.d.ts +42 -1
- package/lib/features/session-mainline.js +29 -3
- package/lib/features/session-resume.d.ts +1 -1
- package/lib/gateway/inbound.d.ts +14 -0
- package/lib/gateway/inbound.js +41 -0
- package/lib/i18n.d.ts +1 -0
- package/lib/i18n.js +2 -0
- package/lib/index.js +142 -65
- package/lib/settings.js +5 -2
- package/lib/startup.d.ts +1 -1
- package/lib/startup.js +14 -7
- package/lib/stream/live.d.ts +0 -37
- package/lib/stream/live.js +10 -58
- package/lib/stream/tool-view.d.ts +12 -2
- package/lib/stream/tool-view.js +32 -2
- package/package.json +10 -12
- package/lib/dsh/api-proxy-face.d.ts +0 -390
- package/lib/dsh/api-proxy-face.js +0 -510
- package/lib/features/image-submission.d.ts +0 -67
- package/lib/features/image-submission.js +0 -57
|
@@ -95,9 +95,105 @@ export function createAdapterManagementHandler(deps) {
|
|
|
95
95
|
return Promise.resolve({ ok: false, error: { code: 'bad-request', message: `Unknown ${DISCORD_RPC_CHANNEL} endpoint.` } });
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
function readBody(request) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
request.on('data', chunk => { chunks.push(chunk); });
|
|
102
|
+
request.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')); });
|
|
103
|
+
request.on('error', reject);
|
|
102
104
|
});
|
|
103
105
|
}
|
|
106
|
+
/** The endpoint segment path under the channel, validated like the host's. */
|
|
107
|
+
function endpointOf(channel, url) {
|
|
108
|
+
const pathname = url === undefined ? '' : url.split('?')[0] ?? '';
|
|
109
|
+
if (!pathname.startsWith(`${channel}/`))
|
|
110
|
+
return undefined;
|
|
111
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
112
|
+
const valid = endpoint.split('/').every(segment => segment !== '' && segment !== '.' && segment !== '..' && /^[A-Za-z0-9_$.-]+$/.test(segment));
|
|
113
|
+
return valid ? endpoint : undefined;
|
|
114
|
+
}
|
|
115
|
+
function respondEnvelope(response, rpcId, result) {
|
|
116
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
117
|
+
response.end(JSON.stringify({ type: 'server-response', rpcId, result }));
|
|
118
|
+
}
|
|
119
|
+
function badRequest(response, rpcId, message) {
|
|
120
|
+
respondEnvelope(response, rpcId, { ok: false, error: { code: 'gateway/bad-request', message, details: { issues: [] } } });
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Serve one channel request: the connection service's fence first (the
|
|
124
|
+
* browser-auth + trusted-host verdict), then the host's channel wire
|
|
125
|
+
* protocol — POST + JSON `client-request` envelope whose method must match
|
|
126
|
+
* the endpoint, answered with a `server-response` envelope.
|
|
127
|
+
*/
|
|
128
|
+
async function serveChannel(connection, handler, request, response) {
|
|
129
|
+
const rejection = connection.requestRejection(request);
|
|
130
|
+
if (rejection !== undefined) {
|
|
131
|
+
response.writeHead(rejection);
|
|
132
|
+
response.end(rejection === 401 ? 'unauthorized' : 'forbidden');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const endpoint = endpointOf(DISCORD_RPC_CHANNEL, request.url);
|
|
136
|
+
if (request.method !== 'POST' || endpoint === undefined) {
|
|
137
|
+
response.writeHead(404);
|
|
138
|
+
response.end('not found');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const contentType = request.headers['content-type'];
|
|
142
|
+
const contentTypeText = (Array.isArray(contentType) ? contentType[0] : contentType)?.split(';', 1)[0]?.trim().toLowerCase();
|
|
143
|
+
if (contentTypeText !== 'application/json') {
|
|
144
|
+
response.writeHead(415);
|
|
145
|
+
response.end('content type must be application/json');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let body;
|
|
149
|
+
try {
|
|
150
|
+
body = JSON.parse(await readBody(request));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
response.writeHead(400);
|
|
154
|
+
response.end('body is not JSON');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const message = body;
|
|
158
|
+
const rpcId = typeof message.rpcId === 'string' ? message.rpcId : '';
|
|
159
|
+
if (message.type !== 'client-request' || rpcId === '' || typeof message.method !== 'string'
|
|
160
|
+
|| typeof message.payload !== 'object' || message.payload === null || Array.isArray(message.payload)) {
|
|
161
|
+
badRequest(response, rpcId, 'invalid client-request message');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (message.method !== endpoint) {
|
|
165
|
+
badRequest(response, rpcId, `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const result = await handler(endpoint, message.payload, undefined);
|
|
170
|
+
respondEnvelope(response, rpcId, result);
|
|
171
|
+
}
|
|
172
|
+
catch (cause) {
|
|
173
|
+
response.writeHead(500);
|
|
174
|
+
response.end(`handler failure: ${String(cause)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Register the management channel on the webServer through the plugin's own
|
|
179
|
+
* context effect. The 0.1.6 host's `connection.rpc.handle` mounts through
|
|
180
|
+
* the connection service's OWN inject scope — an external plugin's call is
|
|
181
|
+
* rejected ("cannot get property webServer without inject") and a runtime
|
|
182
|
+
* ctx.inject wrapper never fires on an already-started plugin, so the
|
|
183
|
+
* sanctioned shape is: our prefix route, the connection service's fence
|
|
184
|
+
* method, and the channel envelope replicated verbatim.
|
|
185
|
+
*/
|
|
186
|
+
export function installAdapterStatusRpc(ctx, tracker, deps = { tracker }) {
|
|
187
|
+
const connection = ctx.get('connection');
|
|
188
|
+
const webServer = ctx.get('webServer');
|
|
189
|
+
const handler = createAdapterManagementHandler(deps);
|
|
190
|
+
const disposer = ctx.effect(() => webServer.register({
|
|
191
|
+
kind: 'prefix',
|
|
192
|
+
path: DISCORD_RPC_CHANNEL,
|
|
193
|
+
handler: (request, response) => serveChannel(connection, handler, request, response),
|
|
194
|
+
}), 'dsh-discord management channel');
|
|
195
|
+
return () => {
|
|
196
|
+
if (typeof disposer === 'function')
|
|
197
|
+
disposer();
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -68,7 +68,7 @@ export interface ApprovalClick {
|
|
|
68
68
|
}
|
|
69
69
|
export type ApprovalDecision = {
|
|
70
70
|
allowed: true;
|
|
71
|
-
/** Respond data built solely from the record, for
|
|
71
|
+
/** Respond data built solely from the record, for the settle port. */
|
|
72
72
|
respond: {
|
|
73
73
|
rpcId: string;
|
|
74
74
|
sessionId: string;
|
|
@@ -37,10 +37,21 @@ export interface ImageAttachment {
|
|
|
37
37
|
declaredSize: number;
|
|
38
38
|
contentType: string;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The production download port (16.50): every fetch rides the safe boundary
|
|
42
|
+
* — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
|
|
43
|
+
* supported media types only.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createSafeImageDownloadPort(): ImageDownloadPort;
|
|
40
46
|
export type ImageCollectionResult = {
|
|
41
47
|
outcome: 'collected';
|
|
42
48
|
images: number;
|
|
43
49
|
totalBytes: number;
|
|
50
|
+
/** The downloaded images in attachment order (16.50): callers encode them for submission. */
|
|
51
|
+
downloaded: ReadonlyArray<{
|
|
52
|
+
mediaType: string;
|
|
53
|
+
body: Uint8Array;
|
|
54
|
+
}>;
|
|
44
55
|
} | {
|
|
45
56
|
outcome: 'too-large';
|
|
46
57
|
reason: 'declared' | 'actual' | 'aggregate';
|
|
@@ -8,10 +8,22 @@
|
|
|
8
8
|
* bounded in practice by Discord CDN attachment limits and the aggregate
|
|
9
9
|
* cap, not by streaming. Refusals are plain values.
|
|
10
10
|
*/
|
|
11
|
+
import { createHttpFetchPort, fetchSafeImage } from './image-download.js';
|
|
11
12
|
/** Per-image cap (design: bounded by DSH-advertised limits; adapter-capped). */
|
|
12
13
|
export const MAX_IMAGE_BYTES = 8 * 1_024 * 1_024;
|
|
13
14
|
/** Aggregate cap across all images of one message. */
|
|
14
15
|
export const MAX_AGGREGATE_IMAGE_BYTES = 24 * 1_024 * 1_024;
|
|
16
|
+
/**
|
|
17
|
+
* The production download port (16.50): every fetch rides the safe boundary
|
|
18
|
+
* — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
|
|
19
|
+
* supported media types only.
|
|
20
|
+
*/
|
|
21
|
+
export function createSafeImageDownloadPort() {
|
|
22
|
+
const http = createHttpFetchPort();
|
|
23
|
+
return {
|
|
24
|
+
download: request => fetchSafeImage(http, { url: request.url }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
15
27
|
/**
|
|
16
28
|
* Wrap the safe download boundary with byte-count enforcement: the body size
|
|
17
29
|
* is checked against the remaining aggregate budget before it is accepted,
|
|
@@ -35,6 +47,7 @@ export function collectImages(port, request) {
|
|
|
35
47
|
let remainingAggregate = MAX_AGGREGATE_IMAGE_BYTES;
|
|
36
48
|
let images = 0;
|
|
37
49
|
let totalBytes = 0;
|
|
50
|
+
const downloaded = [];
|
|
38
51
|
const inner = port;
|
|
39
52
|
async function run() {
|
|
40
53
|
for (const attachment of request.attachments) {
|
|
@@ -50,30 +63,31 @@ export function collectImages(port, request) {
|
|
|
50
63
|
const timeout = new Promise((resolve) => {
|
|
51
64
|
timer = setTimeout(() => { resolve('timeout'); }, request.timeoutMs);
|
|
52
65
|
});
|
|
53
|
-
const
|
|
66
|
+
const downloadedImage = await Promise.race([
|
|
54
67
|
bounded.download({ url: attachment.url }).then((result) => result),
|
|
55
68
|
timeout,
|
|
56
69
|
]).finally(() => {
|
|
57
70
|
if (timer !== undefined)
|
|
58
71
|
clearTimeout(timer);
|
|
59
72
|
});
|
|
60
|
-
if (
|
|
73
|
+
if (downloadedImage === 'timeout')
|
|
61
74
|
return { outcome: 'timeout' };
|
|
62
|
-
if (
|
|
63
|
-
if (
|
|
75
|
+
if (downloadedImage.outcome === 'downloaded') {
|
|
76
|
+
if (downloadedImage.body.byteLength > MAX_IMAGE_BYTES) {
|
|
64
77
|
return { outcome: 'too-large', reason: 'actual' };
|
|
65
78
|
}
|
|
66
79
|
images += 1;
|
|
67
|
-
totalBytes +=
|
|
68
|
-
remainingAggregate -=
|
|
80
|
+
totalBytes += downloadedImage.body.byteLength;
|
|
81
|
+
remainingAggregate -= downloadedImage.body.byteLength;
|
|
82
|
+
downloaded.push({ mediaType: downloadedImage.mediaType, body: downloadedImage.body });
|
|
69
83
|
continue;
|
|
70
84
|
}
|
|
71
|
-
if (
|
|
85
|
+
if (downloadedImage.outcome === 'http-error' && downloadedImage.status === 413) {
|
|
72
86
|
return { outcome: 'too-large', reason: 'actual' };
|
|
73
87
|
}
|
|
74
88
|
return { outcome: 'download-failed' };
|
|
75
89
|
}
|
|
76
|
-
return { outcome: 'collected', images, totalBytes };
|
|
90
|
+
return { outcome: 'collected', images, totalBytes, downloaded };
|
|
77
91
|
}
|
|
78
92
|
return run();
|
|
79
93
|
}
|
|
@@ -46,3 +46,9 @@ export type SafeImageResult = {
|
|
|
46
46
|
export declare function fetchSafeImage(port: HttpFetchPort, request: {
|
|
47
47
|
url: string;
|
|
48
48
|
}): Promise<SafeImageResult>;
|
|
49
|
+
/**
|
|
50
|
+
* The production fetch boundary over the platform `fetch` (16.50). Redirects
|
|
51
|
+
* are NOT followed here — `fetchSafeImage` revalidates every hop itself —
|
|
52
|
+
* and the body is only read for a terminal 200 response.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createHttpFetchPort(): HttpFetchPort;
|
|
@@ -75,3 +75,24 @@ function isParseable(rawUrl) {
|
|
|
75
75
|
export function fetchSafeImage(port, request) {
|
|
76
76
|
return fetchOnce(port, MAX_REDIRECTS, request);
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The production fetch boundary over the platform `fetch` (16.50). Redirects
|
|
80
|
+
* are NOT followed here — `fetchSafeImage` revalidates every hop itself —
|
|
81
|
+
* and the body is only read for a terminal 200 response.
|
|
82
|
+
*/
|
|
83
|
+
export function createHttpFetchPort() {
|
|
84
|
+
return {
|
|
85
|
+
async fetch(url) {
|
|
86
|
+
const response = await fetch(url, { redirect: 'manual' });
|
|
87
|
+
const location = response.headers.get('location');
|
|
88
|
+
const contentType = response.headers.get('content-type');
|
|
89
|
+
const body = response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
|
90
|
+
return {
|
|
91
|
+
status: response.status,
|
|
92
|
+
contentType: contentType ?? undefined,
|
|
93
|
+
location: location ?? undefined,
|
|
94
|
+
body,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -11,7 +11,7 @@ import type { SharedRestClient } from '../discord/rest.js';
|
|
|
11
11
|
import { type PolicyTable } from '../policy/authorization.js';
|
|
12
12
|
import { type ProjectListPort } from './project-list.js';
|
|
13
13
|
import type { WorkspaceResolver } from './project-bind.js';
|
|
14
|
-
import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailOutcome } from '../dsh/
|
|
14
|
+
import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailOutcome } from '../dsh/host-face.js';
|
|
15
15
|
import type { TurnTracker } from './turn-ownership.js';
|
|
16
16
|
import { type DshApprovalRespondPort } from './approval-routing.js';
|
|
17
17
|
import { type DshModelPort } from './model-control.js';
|
|
@@ -20,7 +20,7 @@ import type { ApprovalStore } from './approval-store.js';
|
|
|
20
20
|
import type { ChannelBinding } from '../state/records.js';
|
|
21
21
|
import type { NormalizedInteraction } from '../gateway/inbound.js';
|
|
22
22
|
import type { CopyTable } from '../i18n.js';
|
|
23
|
-
/** The DSH faces the control commands submit through (
|
|
23
|
+
/** The DSH faces the control commands submit through (host-face-backed). */
|
|
24
24
|
export interface InteractionDshFace {
|
|
25
25
|
cancel(sessionId: string): Promise<CancelOutcome>;
|
|
26
26
|
steer(sessionId: string, prompt: string): Promise<PromptOutcome>;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* any mutation.
|
|
12
12
|
*/
|
|
13
13
|
import type { AccessDecision } from '../policy/authorization.js';
|
|
14
|
-
import type { ModelSelectionShape, SessionModelsShape } from '../dsh/
|
|
14
|
+
import type { ModelSelectionShape, SessionModelsShape } from '../dsh/host-face.js';
|
|
15
15
|
export interface DshModelPort {
|
|
16
16
|
models(sessionId: string): Promise<{
|
|
17
17
|
outcome: 'completed';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `/project list` discovery (design.md §3, §13). The feature talks to DSH
|
|
3
|
-
* through one narrow port (satisfied by the
|
|
3
|
+
* through one narrow port (satisfied by the host-face adapter in production,
|
|
4
4
|
* by fakes in tests): every registered Workspace is selectable, labels come
|
|
5
5
|
* from the least-disclosure policy (safe titles, opaque `ws:` references,
|
|
6
6
|
* duplicate disambiguation); per amended design §3 the bind autocomplete
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `/project list` discovery (design.md §3, §13). The feature talks to DSH
|
|
3
|
-
* through one narrow port (satisfied by the
|
|
3
|
+
* through one narrow port (satisfied by the host-face adapter in production,
|
|
4
4
|
* by fakes in tests): every registered Workspace is selectable, labels come
|
|
5
5
|
* from the least-disclosure policy (safe titles, opaque `ws:` references,
|
|
6
6
|
* duplicate disambiguation); per amended design §3 the bind autocomplete
|
|
@@ -15,6 +15,8 @@ export interface DshPromptPort {
|
|
|
15
15
|
sessionId: string;
|
|
16
16
|
prompt: string;
|
|
17
17
|
mode: 'queue';
|
|
18
|
+
/** Collected images (16.50); omitted for text-only prompts so legacy shapes are untouched. */
|
|
19
|
+
images?: ReadonlyArray<PromptRequestImage>;
|
|
18
20
|
}): Promise<{
|
|
19
21
|
outcome: 'accepted';
|
|
20
22
|
} | {
|
|
@@ -24,6 +26,11 @@ export interface DshPromptPort {
|
|
|
24
26
|
outcome: 'unknown';
|
|
25
27
|
}>;
|
|
26
28
|
}
|
|
29
|
+
/** One collected image, ready for the prompt parts encoding. */
|
|
30
|
+
export interface PromptRequestImage {
|
|
31
|
+
mediaType: string;
|
|
32
|
+
base64: string;
|
|
33
|
+
}
|
|
27
34
|
export interface PromptSubmissionDeps {
|
|
28
35
|
prompts: DshPromptPort;
|
|
29
36
|
intents: IntentStore;
|
|
@@ -44,6 +51,8 @@ export interface PromptRequest {
|
|
|
44
51
|
requestId: string;
|
|
45
52
|
sessionId: string;
|
|
46
53
|
prompt: string;
|
|
54
|
+
/** Collected images (16.50); omitted for text-only prompts. */
|
|
55
|
+
images?: ReadonlyArray<PromptRequestImage>;
|
|
47
56
|
}
|
|
48
57
|
export declare function createPromptSubmissionFlow(deps: PromptSubmissionDeps): {
|
|
49
58
|
submitOnce(request: PromptRequest): Promise<PromptSubmissionResult>;
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
import { hashPayload } from '../state/intents.js';
|
|
11
11
|
export function createPromptSubmissionFlow(deps) {
|
|
12
12
|
async function submit(requestId, request) {
|
|
13
|
-
const
|
|
13
|
+
const images = request.images ?? [];
|
|
14
|
+
const contentHash = await hashPayload({
|
|
15
|
+
sessionId: request.sessionId,
|
|
16
|
+
prompt: request.prompt,
|
|
17
|
+
...(images.length > 0 ? { images } : {}),
|
|
18
|
+
});
|
|
14
19
|
const claim = await deps.intents.claim({
|
|
15
20
|
messageId: requestId,
|
|
16
21
|
contentHash,
|
|
@@ -26,6 +31,7 @@ export function createPromptSubmissionFlow(deps) {
|
|
|
26
31
|
sessionId: request.sessionId,
|
|
27
32
|
prompt: request.prompt,
|
|
28
33
|
mode: 'queue',
|
|
34
|
+
...(images.length > 0 ? { images } : {}),
|
|
29
35
|
});
|
|
30
36
|
if (submitted.outcome === 'accepted') {
|
|
31
37
|
await deps.intents.resolve(requestId, 'succeeded', deps.nowMs());
|
|
@@ -9,13 +9,44 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { createThreadCreationFlow } from './thread-creation.js';
|
|
11
11
|
import type { createSessionCreationFlow } from './session-creation.js';
|
|
12
|
-
import type { createPromptSubmissionFlow } from './prompt-submission.js';
|
|
12
|
+
import type { createPromptSubmissionFlow, PromptRequestImage } from './prompt-submission.js';
|
|
13
13
|
import type { TurnTracker } from './turn-ownership.js';
|
|
14
|
+
/** Declared image metadata as carried on the normalized wire message. */
|
|
15
|
+
export interface MainlineWireImage {
|
|
16
|
+
url: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
declaredSize: number;
|
|
19
|
+
contentType: string;
|
|
20
|
+
}
|
|
21
|
+
/** One collected image, ready for submission. */
|
|
22
|
+
export type MainlineImage = PromptRequestImage;
|
|
23
|
+
/**
|
|
24
|
+
* The bounded collection boundary (design.md §12, 16.50): declared wire
|
|
25
|
+
* metadata goes in, base64-encoded images come out. The composition root
|
|
26
|
+
* implements it over the safe-download boundary — this layer never touches
|
|
27
|
+
* the network itself.
|
|
28
|
+
*/
|
|
29
|
+
export interface MainlineImageCollector {
|
|
30
|
+
collect(request: {
|
|
31
|
+
attachments: ReadonlyArray<{
|
|
32
|
+
url: string;
|
|
33
|
+
declaredSize: number;
|
|
34
|
+
contentType: string;
|
|
35
|
+
}>;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
outcome: 'collected';
|
|
38
|
+
images: ReadonlyArray<MainlineImage>;
|
|
39
|
+
} | {
|
|
40
|
+
outcome: 'failed';
|
|
41
|
+
reason: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
14
44
|
export interface SessionMainlineDeps {
|
|
15
45
|
threads: ReturnType<typeof createThreadCreationFlow>;
|
|
16
46
|
sessions: ReturnType<typeof createSessionCreationFlow>;
|
|
17
47
|
prompts: ReturnType<typeof createPromptSubmissionFlow>;
|
|
18
48
|
turns: TurnTracker;
|
|
49
|
+
images: MainlineImageCollector;
|
|
19
50
|
}
|
|
20
51
|
export type MentionMainlineResult = {
|
|
21
52
|
outcome: 'admitted';
|
|
@@ -33,6 +64,9 @@ export type MentionMainlineResult = {
|
|
|
33
64
|
outcome: 'prompt-rejected';
|
|
34
65
|
} | {
|
|
35
66
|
outcome: 'prompt-unknown';
|
|
67
|
+
} | {
|
|
68
|
+
outcome: 'image-failed';
|
|
69
|
+
reason: string;
|
|
36
70
|
};
|
|
37
71
|
export interface MentionMainlineRequest {
|
|
38
72
|
applicationId: string;
|
|
@@ -43,6 +77,8 @@ export interface MentionMainlineRequest {
|
|
|
43
77
|
authorId: string;
|
|
44
78
|
workspaceId: string;
|
|
45
79
|
prompt: string;
|
|
80
|
+
/** Declared image attachments (16.50); omitted for text-only mentions. */
|
|
81
|
+
images?: ReadonlyArray<MainlineWireImage>;
|
|
46
82
|
}
|
|
47
83
|
export type ContinuationMainlineResult = {
|
|
48
84
|
outcome: 'queued';
|
|
@@ -54,6 +90,9 @@ export type ContinuationMainlineResult = {
|
|
|
54
90
|
outcome: 'rejected';
|
|
55
91
|
} | {
|
|
56
92
|
outcome: 'unknown';
|
|
93
|
+
} | {
|
|
94
|
+
outcome: 'image-failed';
|
|
95
|
+
reason: string;
|
|
57
96
|
};
|
|
58
97
|
export interface ContinuationMainlineRequest {
|
|
59
98
|
applicationId: string;
|
|
@@ -62,6 +101,8 @@ export interface ContinuationMainlineRequest {
|
|
|
62
101
|
sessionId: string;
|
|
63
102
|
messageId: string;
|
|
64
103
|
prompt: string;
|
|
104
|
+
/** Declared image attachments (16.50); omitted for text-only messages. */
|
|
105
|
+
images?: ReadonlyArray<MainlineWireImage>;
|
|
65
106
|
}
|
|
66
107
|
/**
|
|
67
108
|
* The stable, adapter-owned request id for a submitted prompt: derived from
|
|
@@ -30,17 +30,38 @@ function mapSubmission(result) {
|
|
|
30
30
|
return { outcome: 'conflict' };
|
|
31
31
|
return result.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Collect the request's declared images through the bounded boundary. A
|
|
35
|
+
* text-only request never touches the collector; a failed collection is a
|
|
36
|
+
* first-class outcome so no degraded text-only submission is sent silently.
|
|
37
|
+
*/
|
|
38
|
+
async function collectRequestImages(deps, images) {
|
|
39
|
+
if (images.length === 0)
|
|
40
|
+
return { outcome: 'collected', images: [] };
|
|
41
|
+
const collected = await deps.images.collect({
|
|
42
|
+
attachments: images.map(({ url, declaredSize, contentType }) => ({ url, declaredSize, contentType })),
|
|
43
|
+
});
|
|
44
|
+
if (collected.outcome !== 'collected')
|
|
45
|
+
return { outcome: 'image-failed', reason: collected.reason };
|
|
46
|
+
return collected;
|
|
47
|
+
}
|
|
33
48
|
export function createSessionMainline(deps) {
|
|
34
49
|
return {
|
|
35
50
|
async admitMention(request) {
|
|
51
|
+
const declaredImages = request.images ?? [];
|
|
52
|
+
const images = await collectRequestImages(deps, declaredImages);
|
|
53
|
+
if (images.outcome !== 'collected')
|
|
54
|
+
return images;
|
|
36
55
|
// The source message id is the durable intent: one thread per message,
|
|
37
|
-
// deterministic recovery on redelivery (design.md §4, §10).
|
|
56
|
+
// deterministic recovery on redelivery (design.md §4, §10). An
|
|
57
|
+
// image-only mention names the thread after its first image's filename.
|
|
58
|
+
const title = request.prompt !== '' ? request.prompt : (declaredImages[0]?.filename ?? '');
|
|
38
59
|
const thread = await deps.threads.ensureThread({
|
|
39
60
|
sourceMessageId: request.messageId,
|
|
40
|
-
contentHash: await hashPayload({ prompt: request.prompt }),
|
|
61
|
+
contentHash: await hashPayload({ prompt: request.prompt, images: declaredImages }),
|
|
41
62
|
guildId: request.guildId,
|
|
42
63
|
parentChannelId: request.channelId,
|
|
43
|
-
threadName: safeTitle(
|
|
64
|
+
threadName: safeTitle(title),
|
|
44
65
|
creatorUserId: request.authorId,
|
|
45
66
|
});
|
|
46
67
|
if (thread.outcome === 'conflict')
|
|
@@ -66,6 +87,7 @@ export function createSessionMainline(deps) {
|
|
|
66
87
|
requestId,
|
|
67
88
|
sessionId,
|
|
68
89
|
prompt: request.prompt,
|
|
90
|
+
...(images.images.length > 0 ? { images: images.images } : {}),
|
|
69
91
|
});
|
|
70
92
|
if (submitted.outcome === 'accepted') {
|
|
71
93
|
ownTurn(deps, { sessionId, requestId, threadId });
|
|
@@ -81,11 +103,15 @@ export function createSessionMainline(deps) {
|
|
|
81
103
|
return submitted.outcome === 'rejected' ? { outcome: 'prompt-rejected' } : { outcome: 'prompt-unknown' };
|
|
82
104
|
},
|
|
83
105
|
async continueInThread(request) {
|
|
106
|
+
const images = await collectRequestImages(deps, request.images ?? []);
|
|
107
|
+
if (images.outcome !== 'collected')
|
|
108
|
+
return images;
|
|
84
109
|
const requestId = requestIdFor(request.messageId);
|
|
85
110
|
const submitted = await deps.prompts.submitOnce({
|
|
86
111
|
requestId,
|
|
87
112
|
sessionId: request.sessionId,
|
|
88
113
|
prompt: request.prompt,
|
|
114
|
+
...(images.images.length > 0 ? { images: images.images } : {}),
|
|
89
115
|
});
|
|
90
116
|
if (submitted.outcome === 'accepted') {
|
|
91
117
|
ownTurn(deps, { sessionId: request.sessionId, requestId, threadId: request.threadId });
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* title/id substring, and sort newest-first — Discord autocomplete caps at
|
|
7
7
|
* 25 choices, the caller slices.
|
|
8
8
|
*/
|
|
9
|
-
import type { SessionResumeRow } from '../dsh/
|
|
9
|
+
import type { SessionResumeRow } from '../dsh/host-face.js';
|
|
10
10
|
export type { SessionResumeRow };
|
|
11
11
|
import type { CopyTable } from '../i18n.js';
|
|
12
12
|
import { type SelectorOption } from '../discord/selector.js';
|
package/lib/gateway/inbound.d.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface NormalizedMessage {
|
|
|
40
40
|
mentionedBot: boolean;
|
|
41
41
|
/** The snowflake of the message this one replies to, when present and valid. */
|
|
42
42
|
repliedToId: DiscordSnowflake | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* DECLARED image-attachment metadata (16.50): supported types only,
|
|
45
|
+
* malformed entries skipped, capped at Discord's per-message limit. No
|
|
46
|
+
* bytes cross this boundary — download happens later, behind the
|
|
47
|
+
* allowlisted fetch boundary.
|
|
48
|
+
*/
|
|
49
|
+
images: DiscordImageAttachment[];
|
|
50
|
+
}
|
|
51
|
+
/** One supported image attachment, as declared by the wire. */
|
|
52
|
+
export interface DiscordImageAttachment {
|
|
53
|
+
url: string;
|
|
54
|
+
filename: string;
|
|
55
|
+
declaredSize: number;
|
|
56
|
+
contentType: string;
|
|
43
57
|
}
|
|
44
58
|
/** A validated guild interaction with its actor identity. */
|
|
45
59
|
export interface NormalizedInteraction {
|
package/lib/gateway/inbound.js
CHANGED
|
@@ -40,6 +40,46 @@ function extractRoleIds(payload) {
|
|
|
40
40
|
function asSnowflake(value) {
|
|
41
41
|
return isDiscordSnowflake(value) ? value : undefined;
|
|
42
42
|
}
|
|
43
|
+
/** Image content types the adapter carries (design.md §12). */
|
|
44
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
45
|
+
'image/jpeg',
|
|
46
|
+
'image/png',
|
|
47
|
+
'image/webp',
|
|
48
|
+
'image/gif',
|
|
49
|
+
]);
|
|
50
|
+
/** Discord's per-message attachment cap; a defensive bound on untrusted input. */
|
|
51
|
+
const MAX_MESSAGE_IMAGES = 10;
|
|
52
|
+
/**
|
|
53
|
+
* Narrow untrusted `attachments` onto declared image metadata. Entries that
|
|
54
|
+
* are not records, lack any required field, or declare an unsupported media
|
|
55
|
+
* type are skipped — a malformed attachment never rejects the message.
|
|
56
|
+
*/
|
|
57
|
+
function extractImages(payload) {
|
|
58
|
+
const raw = payload['attachments'];
|
|
59
|
+
if (!Array.isArray(raw))
|
|
60
|
+
return [];
|
|
61
|
+
const images = [];
|
|
62
|
+
for (const entry of raw) {
|
|
63
|
+
if (images.length >= MAX_MESSAGE_IMAGES)
|
|
64
|
+
break;
|
|
65
|
+
if (!isRecord(entry))
|
|
66
|
+
continue;
|
|
67
|
+
const url = entry['url'];
|
|
68
|
+
const filename = entry['filename'];
|
|
69
|
+
const size = entry['size'];
|
|
70
|
+
const contentType = entry['content_type'];
|
|
71
|
+
if (typeof url !== 'string' || url === '')
|
|
72
|
+
continue;
|
|
73
|
+
if (typeof filename !== 'string' || filename === '')
|
|
74
|
+
continue;
|
|
75
|
+
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0)
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof contentType !== 'string' || !SUPPORTED_IMAGE_TYPES.has(contentType))
|
|
78
|
+
continue;
|
|
79
|
+
images.push({ url, filename, declaredSize: size, contentType });
|
|
80
|
+
}
|
|
81
|
+
return images;
|
|
82
|
+
}
|
|
43
83
|
function parseMessage(payload, selfUserId) {
|
|
44
84
|
// Webhook-authored messages are app-driven surfaces, not member input:
|
|
45
85
|
// their author object need not carry `bot: true`, and anyone with
|
|
@@ -93,6 +133,7 @@ function parseMessage(payload, selfUserId) {
|
|
|
93
133
|
content: stripped.text,
|
|
94
134
|
mentionedBot: stripped.mentioned,
|
|
95
135
|
repliedToId,
|
|
136
|
+
images: extractImages(payload),
|
|
96
137
|
},
|
|
97
138
|
};
|
|
98
139
|
}
|
package/lib/i18n.d.ts
CHANGED
package/lib/i18n.js
CHANGED
|
@@ -84,6 +84,7 @@ const zh = {
|
|
|
84
84
|
mainlineSessionRejected: '⚠️ DSH 拒绝了会话创建(工作区可能已失效);请稍后重试或重新 /project bind。',
|
|
85
85
|
mainlineSessionUnknown: '⚠️ 会话创建结果未知;请到 DSH Web 确认后再重试,不会自动重复创建。',
|
|
86
86
|
mainlinePromptRejected: '⚠️ DSH 拒绝了任务提交。',
|
|
87
|
+
mainlineImageFailed: '⚠️ 附件图片获取失败,任务未提交;请重试或改用文字描述。',
|
|
87
88
|
mainlinePromptUnknown: '⚠️ 任务提交结果未知;为避免重复执行不会自动重发,请确认后重试。',
|
|
88
89
|
continuationRejected: '⚠️ 消息提交被 DSH 拒绝。',
|
|
89
90
|
continuationUnknown: '⚠️ 消息提交结果未知;不会自动重发,请确认后重试。',
|
|
@@ -207,6 +208,7 @@ const en = {
|
|
|
207
208
|
mainlineSessionRejected: '⚠️ DSH rejected the session creation (the workspace may be stale); try again later or re-run /project bind.',
|
|
208
209
|
mainlineSessionUnknown: '⚠️ Session creation outcome unknown; confirm on the DSH web console before retrying. It will not be resubmitted automatically.',
|
|
209
210
|
mainlinePromptRejected: '⚠️ DSH rejected the task submission.',
|
|
211
|
+
mainlineImageFailed: '⚠️ Could not fetch the attached image(s); the task was not submitted. Retry or describe it in text.',
|
|
210
212
|
mainlinePromptUnknown: '⚠️ Task submission outcome unknown; it will not be resent automatically to avoid double execution. Confirm and retry.',
|
|
211
213
|
continuationRejected: '⚠️ The message submission was rejected by DSH.',
|
|
212
214
|
continuationUnknown: '⚠️ Message submission outcome unknown; it will not be resent automatically. Confirm and retry.',
|