@kittymi/openclaw-generic-http 0.1.3
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/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/channel/account.d.ts +7 -0
- package/dist/channel/account.js +18 -0
- package/dist/channel/capabilities.d.ts +10 -0
- package/dist/channel/capabilities.js +11 -0
- package/dist/channel/host-adapter.d.ts +28 -0
- package/dist/channel/host-adapter.js +36 -0
- package/dist/channel/lifecycle.d.ts +18 -0
- package/dist/channel/lifecycle.js +28 -0
- package/dist/channel/plugin.d.ts +46 -0
- package/dist/channel/plugin.js +120 -0
- package/dist/channel/probe.d.ts +19 -0
- package/dist/channel/probe.js +149 -0
- package/dist/channel/resolve.d.ts +30 -0
- package/dist/channel/resolve.js +98 -0
- package/dist/channel/stream.d.ts +35 -0
- package/dist/channel/stream.js +127 -0
- package/dist/config/host-config-schema.d.ts +21 -0
- package/dist/config/host-config-schema.js +80 -0
- package/dist/config/loader.d.ts +7 -0
- package/dist/config/loader.js +38 -0
- package/dist/config/schema.d.ts +48 -0
- package/dist/config/schema.js +1 -0
- package/dist/errors/codes.d.ts +11 -0
- package/dist/errors/codes.js +10 -0
- package/dist/errors/exceptions.d.ts +7 -0
- package/dist/errors/exceptions.js +12 -0
- package/dist/inbound/mapper.d.ts +21 -0
- package/dist/inbound/mapper.js +22 -0
- package/dist/inbound/validator.d.ts +4 -0
- package/dist/inbound/validator.js +114 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +31 -0
- package/dist/mapping/conversation-mapper.d.ts +1 -0
- package/dist/mapping/conversation-mapper.js +3 -0
- package/dist/mapping/sender-mapper.d.ts +1 -0
- package/dist/mapping/sender-mapper.js +3 -0
- package/dist/mapping/thread-mapper.d.ts +1 -0
- package/dist/mapping/thread-mapper.js +7 -0
- package/dist/openclaw-entry.d.ts +276 -0
- package/dist/openclaw-entry.js +728 -0
- package/dist/outbound/client.d.ts +6 -0
- package/dist/outbound/client.js +1 -0
- package/dist/outbound/controller.d.ts +15 -0
- package/dist/outbound/controller.js +28 -0
- package/dist/outbound/http-client.d.ts +23 -0
- package/dist/outbound/http-client.js +150 -0
- package/dist/outbound/mapper.d.ts +29 -0
- package/dist/outbound/mapper.js +19 -0
- package/dist/outbound/mock-client.d.ts +18 -0
- package/dist/outbound/mock-client.js +26 -0
- package/dist/outbound/sender.d.ts +3 -0
- package/dist/outbound/sender.js +5 -0
- package/dist/protocol/attachments.d.ts +10 -0
- package/dist/protocol/attachments.js +56 -0
- package/dist/protocol/dto.d.ts +46 -0
- package/dist/protocol/dto.js +1 -0
- package/dist/protocol/serializer.d.ts +1 -0
- package/dist/protocol/serializer.js +3 -0
- package/dist/security/nonce-store.d.ts +30 -0
- package/dist/security/nonce-store.js +32 -0
- package/dist/security/signer.d.ts +10 -0
- package/dist/security/signer.js +20 -0
- package/dist/security/verifier.d.ts +2 -0
- package/dist/security/verifier.js +20 -0
- package/dist/setup-entry.d.ts +351 -0
- package/dist/setup-entry.js +73 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/utils/log.d.ts +1 -0
- package/dist/utils/log.js +3 -0
- package/dist/utils/time.d.ts +1 -0
- package/dist/utils/time.js +3 -0
- package/openclaw.config.schema.json +80 -0
- package/openclaw.plugin.json +175 -0
- package/package.json +72 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OutboundMessageRequest, OutboundMessageResult } from "./mapper.js";
|
|
2
|
+
export interface OutboundClient {
|
|
3
|
+
send(request: OutboundMessageRequest): Promise<OutboundMessageResult>;
|
|
4
|
+
}
|
|
5
|
+
export { HttpOutboundClient } from "./http-client.js";
|
|
6
|
+
export type { HttpOutboundClientOptions } from "./http-client.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HttpOutboundClient } from "./http-client.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OutboundClient } from "./client.js";
|
|
2
|
+
import { type InternalOutboundMessage, type OutboundMessageResult } from "./mapper.js";
|
|
3
|
+
export interface OutboundHandlingSuccess {
|
|
4
|
+
result: OutboundMessageResult;
|
|
5
|
+
}
|
|
6
|
+
export interface OutboundHandlingError {
|
|
7
|
+
success: false;
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
requestId: string;
|
|
11
|
+
details?: Record<string, unknown>;
|
|
12
|
+
retryable: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildOutboundErrorResponse(requestId: string, error: unknown): OutboundHandlingError;
|
|
15
|
+
export declare function handleOutboundMessage(client: OutboundClient, message: InternalOutboundMessage): Promise<OutboundHandlingSuccess>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { GenericHttpPluginError } from "../errors/exceptions.js";
|
|
2
|
+
import { sendOutboundMessage } from "./sender.js";
|
|
3
|
+
export function buildOutboundErrorResponse(requestId, error) {
|
|
4
|
+
if (error instanceof GenericHttpPluginError) {
|
|
5
|
+
return {
|
|
6
|
+
success: false,
|
|
7
|
+
code: error.code,
|
|
8
|
+
message: error.message,
|
|
9
|
+
requestId,
|
|
10
|
+
details: error.details,
|
|
11
|
+
retryable: error.retryable
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
success: false,
|
|
16
|
+
code: "INTERNAL_ERROR",
|
|
17
|
+
message: "Unexpected outbound message handling failure.",
|
|
18
|
+
requestId,
|
|
19
|
+
retryable: true
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function handleOutboundMessage(client, message) {
|
|
23
|
+
// The outbound control path mirrors inbound: normalize the internal message,
|
|
24
|
+
// delegate delivery to the transport client, and return a protocol-shaped
|
|
25
|
+
// result for the caller or HTTP layer.
|
|
26
|
+
const result = await sendOutboundMessage(client, message);
|
|
27
|
+
return { result };
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GenericHttpAccountConfig } from "../config/schema.js";
|
|
2
|
+
import type { OutboundClient } from "./client.js";
|
|
3
|
+
import type { OutboundMessageRequest, OutboundMessageResult } from "./mapper.js";
|
|
4
|
+
export interface HttpOutboundClientOptions {
|
|
5
|
+
nowEpochSeconds?: () => number;
|
|
6
|
+
nonceFactory?: () => string;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Minimal HTTP transport for the generic bridge outbound path.
|
|
11
|
+
*
|
|
12
|
+
* The caller hands over a normalized outbound request, this client signs the
|
|
13
|
+
* serialized body with the account secret, and then posts it to the configured
|
|
14
|
+
* third-party bridge endpoint.
|
|
15
|
+
*/
|
|
16
|
+
export declare class HttpOutboundClient implements OutboundClient {
|
|
17
|
+
private readonly accountConfig;
|
|
18
|
+
private readonly nowEpochSeconds;
|
|
19
|
+
private readonly nonceFactory;
|
|
20
|
+
private readonly fetchImpl;
|
|
21
|
+
constructor(accountConfig: GenericHttpAccountConfig, options?: HttpOutboundClientOptions);
|
|
22
|
+
send(request: OutboundMessageRequest): Promise<OutboundMessageResult>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { ERROR_CODES } from "../errors/codes.js";
|
|
2
|
+
import { GenericHttpPluginError } from "../errors/exceptions.js";
|
|
3
|
+
import { normalizeAttachment } from "../protocol/attachments.js";
|
|
4
|
+
import { serializeProtocolObject } from "../protocol/serializer.js";
|
|
5
|
+
import { signPayload } from "../security/signer.js";
|
|
6
|
+
function normalizeOutboundRequest(request) {
|
|
7
|
+
return {
|
|
8
|
+
...request,
|
|
9
|
+
message: {
|
|
10
|
+
...request.message,
|
|
11
|
+
attachments: (request.message.attachments ?? []).map((attachment) => normalizeAttachment(attachment))
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function createTimeoutSignal(timeoutMillis) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), timeoutMillis);
|
|
18
|
+
controller.signal.addEventListener("abort", () => clearTimeout(timer), {
|
|
19
|
+
once: true
|
|
20
|
+
});
|
|
21
|
+
return controller.signal;
|
|
22
|
+
}
|
|
23
|
+
function buildOutboundEndpoint(baseUrl) {
|
|
24
|
+
if (baseUrl.trim() === "") {
|
|
25
|
+
throw new GenericHttpPluginError(ERROR_CODES.INVALID_REQUEST, "Outbound account config requires a non-empty baseUrl.", { field: "baseUrl" });
|
|
26
|
+
}
|
|
27
|
+
return new URL("/outbound/messages", baseUrl).toString();
|
|
28
|
+
}
|
|
29
|
+
function buildOutboundHeaders(accountConfig, request, signature, timestamp, nonce) {
|
|
30
|
+
return {
|
|
31
|
+
accept: "application/json",
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
"x-api-key": accountConfig.apiKey ?? "",
|
|
34
|
+
"x-generic-http-version": "1",
|
|
35
|
+
"x-nonce": nonce,
|
|
36
|
+
"x-request-id": request.requestId,
|
|
37
|
+
"x-signature": signature,
|
|
38
|
+
"x-timestamp": timestamp
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function parseOutboundResult(value) {
|
|
42
|
+
if (typeof value !== "object" || value === null) {
|
|
43
|
+
throw new GenericHttpPluginError(ERROR_CODES.INTERNAL_ERROR, "Outbound endpoint returned a non-object response.", { responseType: typeof value });
|
|
44
|
+
}
|
|
45
|
+
const result = value;
|
|
46
|
+
if (result.success !== true ||
|
|
47
|
+
result.code !== "DELIVERED" ||
|
|
48
|
+
typeof result.providerMessageId !== "string" ||
|
|
49
|
+
typeof result.acceptedAt !== "string") {
|
|
50
|
+
throw new GenericHttpPluginError(ERROR_CODES.INTERNAL_ERROR, "Outbound endpoint returned an invalid delivery result.", {
|
|
51
|
+
response: value
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
code: "DELIVERED",
|
|
57
|
+
providerMessageId: result.providerMessageId,
|
|
58
|
+
acceptedAt: result.acceptedAt,
|
|
59
|
+
metadata: typeof result.metadata === "object" && result.metadata !== null
|
|
60
|
+
? result.metadata
|
|
61
|
+
: {}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function isRetryableStatus(status) {
|
|
65
|
+
return status === 408 || status === 429 || status >= 500;
|
|
66
|
+
}
|
|
67
|
+
function shouldRetry(error) {
|
|
68
|
+
if (error instanceof GenericHttpPluginError) {
|
|
69
|
+
return error.retryable;
|
|
70
|
+
}
|
|
71
|
+
if (error instanceof Error) {
|
|
72
|
+
return error.name === "AbortError" || error.name === "TypeError";
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
function createTransportError(code, message, details, retryable = false) {
|
|
77
|
+
return new GenericHttpPluginError(code, message, details, retryable);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Minimal HTTP transport for the generic bridge outbound path.
|
|
81
|
+
*
|
|
82
|
+
* The caller hands over a normalized outbound request, this client signs the
|
|
83
|
+
* serialized body with the account secret, and then posts it to the configured
|
|
84
|
+
* third-party bridge endpoint.
|
|
85
|
+
*/
|
|
86
|
+
export class HttpOutboundClient {
|
|
87
|
+
accountConfig;
|
|
88
|
+
nowEpochSeconds;
|
|
89
|
+
nonceFactory;
|
|
90
|
+
fetchImpl;
|
|
91
|
+
constructor(accountConfig, options = {}) {
|
|
92
|
+
this.accountConfig = accountConfig;
|
|
93
|
+
this.nowEpochSeconds =
|
|
94
|
+
options.nowEpochSeconds ?? (() => Math.floor(Date.now() / 1000));
|
|
95
|
+
this.nonceFactory = options.nonceFactory ?? (() => crypto.randomUUID());
|
|
96
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
97
|
+
}
|
|
98
|
+
async send(request) {
|
|
99
|
+
const endpoint = buildOutboundEndpoint(this.accountConfig.baseUrl);
|
|
100
|
+
const normalizedRequest = normalizeOutboundRequest(request);
|
|
101
|
+
const rawBody = serializeProtocolObject(normalizedRequest);
|
|
102
|
+
const timestamp = String(this.nowEpochSeconds());
|
|
103
|
+
const nonce = this.nonceFactory();
|
|
104
|
+
const signingSecret = this.accountConfig.outboundSecret ?? this.accountConfig.signingSecret;
|
|
105
|
+
if (signingSecret === undefined || signingSecret.trim() === "") {
|
|
106
|
+
throw new GenericHttpPluginError(ERROR_CODES.INVALID_REQUEST, "Outbound account config requires signingSecret or outboundSecret.", {
|
|
107
|
+
accountId: request.accountId
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const path = new URL(endpoint).pathname;
|
|
111
|
+
const signature = signPayload(signingSecret, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
path,
|
|
114
|
+
timestamp,
|
|
115
|
+
nonce,
|
|
116
|
+
rawBody
|
|
117
|
+
});
|
|
118
|
+
const maxAttempts = Math.max(1, (this.accountConfig.maxRetries ?? 0) + 1);
|
|
119
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
120
|
+
try {
|
|
121
|
+
const response = await this.fetchImpl(endpoint, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: buildOutboundHeaders(this.accountConfig, normalizedRequest, signature, timestamp, nonce),
|
|
124
|
+
body: rawBody,
|
|
125
|
+
signal: createTimeoutSignal(this.accountConfig.readTimeoutMillis ?? 10000)
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw createTransportError(ERROR_CODES.INTERNAL_ERROR, `Outbound HTTP request failed with status ${response.status} ${response.statusText}.`, {
|
|
129
|
+
status: response.status,
|
|
130
|
+
statusText: response.statusText
|
|
131
|
+
}, isRetryableStatus(response.status));
|
|
132
|
+
}
|
|
133
|
+
return parseOutboundResult(await response.json());
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (attempt >= maxAttempts || !shouldRetry(error)) {
|
|
137
|
+
if (error instanceof GenericHttpPluginError) {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
throw createTransportError(ERROR_CODES.INTERNAL_ERROR, "Outbound HTTP transport failed before a valid delivery result was received.", {
|
|
141
|
+
cause: error instanceof Error
|
|
142
|
+
? `${error.name}: ${error.message}`
|
|
143
|
+
: String(error)
|
|
144
|
+
}, true);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw createTransportError(ERROR_CODES.INTERNAL_ERROR, "Outbound HTTP transport exhausted retries without returning a delivery result.", { accountId: request.accountId }, true);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AttachmentDto, ConversationDto, MessageDto } from "../protocol/dto.js";
|
|
2
|
+
export interface OutboundMessageRequest {
|
|
3
|
+
requestId: string;
|
|
4
|
+
accountId: string;
|
|
5
|
+
conversation: ConversationDto;
|
|
6
|
+
threadId: string | null;
|
|
7
|
+
message: MessageDto;
|
|
8
|
+
metadata: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface OutboundMessageResult {
|
|
11
|
+
success: true;
|
|
12
|
+
code: "DELIVERED";
|
|
13
|
+
providerMessageId: string;
|
|
14
|
+
acceptedAt: string;
|
|
15
|
+
metadata: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
export interface InternalOutboundMessage {
|
|
18
|
+
requestId: string;
|
|
19
|
+
accountId: string;
|
|
20
|
+
conversationId: string;
|
|
21
|
+
conversationType: ConversationDto["type"];
|
|
22
|
+
threadId?: string | null;
|
|
23
|
+
messageId: string;
|
|
24
|
+
text?: string | null;
|
|
25
|
+
attachments?: AttachmentDto[];
|
|
26
|
+
replyToMessageId?: string | null;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export declare function mapOutboundMessage(message: InternalOutboundMessage): OutboundMessageRequest;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { normalizeAttachment } from "../protocol/attachments.js";
|
|
2
|
+
export function mapOutboundMessage(message) {
|
|
3
|
+
return {
|
|
4
|
+
requestId: message.requestId,
|
|
5
|
+
accountId: message.accountId,
|
|
6
|
+
conversation: {
|
|
7
|
+
conversationId: message.conversationId,
|
|
8
|
+
type: message.conversationType
|
|
9
|
+
},
|
|
10
|
+
threadId: message.threadId ?? null,
|
|
11
|
+
message: {
|
|
12
|
+
messageId: message.messageId,
|
|
13
|
+
text: message.text ?? null,
|
|
14
|
+
attachments: (message.attachments ?? []).map((attachment) => normalizeAttachment(attachment)),
|
|
15
|
+
replyToMessageId: message.replyToMessageId ?? null
|
|
16
|
+
},
|
|
17
|
+
metadata: message.metadata ?? {}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OutboundClient } from "./client.js";
|
|
2
|
+
import type { OutboundMessageRequest, OutboundMessageResult } from "./mapper.js";
|
|
3
|
+
export interface MockOutboundClientOptions {
|
|
4
|
+
nowIsoString?: () => string;
|
|
5
|
+
providerMessageIdPrefix?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Simple in-memory outbound client for local demos and tests. It lets the
|
|
9
|
+
* plugin exercise the full outbound mapping/sender/controller flow before a
|
|
10
|
+
* real HTTP transport is introduced.
|
|
11
|
+
*/
|
|
12
|
+
export declare class MockOutboundClient implements OutboundClient {
|
|
13
|
+
readonly requests: OutboundMessageRequest[];
|
|
14
|
+
private readonly nowIsoString;
|
|
15
|
+
private readonly providerMessageIdPrefix;
|
|
16
|
+
constructor(options?: MockOutboundClientOptions);
|
|
17
|
+
send(request: OutboundMessageRequest): Promise<OutboundMessageResult>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory outbound client for local demos and tests. It lets the
|
|
3
|
+
* plugin exercise the full outbound mapping/sender/controller flow before a
|
|
4
|
+
* real HTTP transport is introduced.
|
|
5
|
+
*/
|
|
6
|
+
export class MockOutboundClient {
|
|
7
|
+
requests = [];
|
|
8
|
+
nowIsoString;
|
|
9
|
+
providerMessageIdPrefix;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.nowIsoString = options.nowIsoString ?? (() => new Date().toISOString());
|
|
12
|
+
this.providerMessageIdPrefix = options.providerMessageIdPrefix ?? "mock";
|
|
13
|
+
}
|
|
14
|
+
async send(request) {
|
|
15
|
+
this.requests.push(request);
|
|
16
|
+
return {
|
|
17
|
+
success: true,
|
|
18
|
+
code: "DELIVERED",
|
|
19
|
+
providerMessageId: `${this.providerMessageIdPrefix}-${request.message.messageId}`,
|
|
20
|
+
acceptedAt: this.nowIsoString(),
|
|
21
|
+
metadata: {
|
|
22
|
+
transport: "mock"
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { OutboundClient } from "./client.js";
|
|
2
|
+
import { type InternalOutboundMessage, type OutboundMessageResult } from "./mapper.js";
|
|
3
|
+
export declare function sendOutboundMessage(client: OutboundClient, message: InternalOutboundMessage): Promise<OutboundMessageResult>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AttachmentDto } from "./dto.js";
|
|
2
|
+
export declare const DEFAULT_MAX_ATTACHMENT_SIZE_BYTES: number;
|
|
3
|
+
export interface NormalizeAttachmentOptions {
|
|
4
|
+
maxAttachmentSizeBytes?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Centralize attachment shaping rules so inbound validation and outbound
|
|
8
|
+
* serialization do not drift apart when file/image support evolves.
|
|
9
|
+
*/
|
|
10
|
+
export declare function normalizeAttachment(attachment: AttachmentDto, options?: NormalizeAttachmentOptions): AttachmentDto;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ERROR_CODES } from "../errors/codes.js";
|
|
2
|
+
import { GenericHttpPluginError } from "../errors/exceptions.js";
|
|
3
|
+
export const DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|
4
|
+
function inferAttachmentKind(attachment) {
|
|
5
|
+
if (attachment.kind !== undefined) {
|
|
6
|
+
return attachment.kind;
|
|
7
|
+
}
|
|
8
|
+
if (attachment.contentType?.startsWith("image/") === true) {
|
|
9
|
+
return "image";
|
|
10
|
+
}
|
|
11
|
+
return "file";
|
|
12
|
+
}
|
|
13
|
+
function normalizeAttachmentName(attachment) {
|
|
14
|
+
if (attachment.name === undefined) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const normalized = attachment.name.trim();
|
|
18
|
+
return normalized === "" ? undefined : normalized;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Centralize attachment shaping rules so inbound validation and outbound
|
|
22
|
+
* serialization do not drift apart when file/image support evolves.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeAttachment(attachment, options = {}) {
|
|
25
|
+
const normalized = {
|
|
26
|
+
kind: inferAttachmentKind(attachment),
|
|
27
|
+
id: attachment.id,
|
|
28
|
+
name: normalizeAttachmentName(attachment),
|
|
29
|
+
contentType: attachment.contentType,
|
|
30
|
+
url: attachment.url,
|
|
31
|
+
contentBase64: attachment.contentBase64,
|
|
32
|
+
sizeBytes: attachment.sizeBytes,
|
|
33
|
+
caption: attachment.caption ?? null,
|
|
34
|
+
altText: attachment.altText ?? null,
|
|
35
|
+
previewUrl: attachment.previewUrl,
|
|
36
|
+
metadata: attachment.metadata ?? {}
|
|
37
|
+
};
|
|
38
|
+
const maxAttachmentSizeBytes = options.maxAttachmentSizeBytes ?? DEFAULT_MAX_ATTACHMENT_SIZE_BYTES;
|
|
39
|
+
if (normalized.sizeBytes !== undefined &&
|
|
40
|
+
normalized.sizeBytes > maxAttachmentSizeBytes) {
|
|
41
|
+
throw new GenericHttpPluginError(ERROR_CODES.INVALID_REQUEST, "Attachment size exceeds the supported maximum.", {
|
|
42
|
+
maxAttachmentSizeBytes,
|
|
43
|
+
sizeBytes: normalized.sizeBytes
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (normalized.kind === "image") {
|
|
47
|
+
if (normalized.contentType !== undefined &&
|
|
48
|
+
!normalized.contentType.startsWith("image/")) {
|
|
49
|
+
throw new GenericHttpPluginError(ERROR_CODES.INVALID_FIELD_FORMAT, "Image attachment contentType must start with image/.", {
|
|
50
|
+
field: "contentType",
|
|
51
|
+
contentType: normalized.contentType
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface ConversationDto {
|
|
2
|
+
conversationId: string;
|
|
3
|
+
type: ConversationType;
|
|
4
|
+
title?: string | null;
|
|
5
|
+
metadata?: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
export type ConversationType = "dm" | "group" | "room" | "ticket";
|
|
8
|
+
export type SenderType = "user" | "bot" | "system";
|
|
9
|
+
export type AttachmentKind = "file" | "image";
|
|
10
|
+
export interface SenderDto {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string | null;
|
|
13
|
+
type: SenderType;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface AttachmentDto {
|
|
17
|
+
kind?: AttachmentKind;
|
|
18
|
+
id?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
contentType?: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
contentBase64?: string;
|
|
23
|
+
sizeBytes?: number;
|
|
24
|
+
caption?: string | null;
|
|
25
|
+
altText?: string | null;
|
|
26
|
+
previewUrl?: string;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface MessageDto {
|
|
30
|
+
messageId: string;
|
|
31
|
+
text?: string | null;
|
|
32
|
+
attachments?: AttachmentDto[];
|
|
33
|
+
replyToMessageId?: string | null;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface InboundMessageRequestDto {
|
|
37
|
+
eventId: string;
|
|
38
|
+
accountId: string;
|
|
39
|
+
conversation: ConversationDto;
|
|
40
|
+
threadId?: string | null;
|
|
41
|
+
sender: SenderDto;
|
|
42
|
+
message: MessageDto;
|
|
43
|
+
occurredAt?: string;
|
|
44
|
+
idempotencyKey?: string;
|
|
45
|
+
metadata?: Record<string, unknown>;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function serializeProtocolObject(value: unknown): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface NonceStore {
|
|
2
|
+
tryUse(nonce: string, timestampEpochSeconds: number): boolean;
|
|
3
|
+
}
|
|
4
|
+
export interface InMemoryNonceStoreOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Maximum allowed age window for accepted nonces, in seconds.
|
|
7
|
+
*
|
|
8
|
+
* Entries older than this window are evicted during normal insert/check
|
|
9
|
+
* operations so the store stays bounded for the minimum viable runtime.
|
|
10
|
+
*/
|
|
11
|
+
ttlSeconds?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Clock source override for tests and deterministic validation flows.
|
|
14
|
+
*/
|
|
15
|
+
nowEpochSeconds?: () => number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Small in-memory nonce store for local development and single-process
|
|
19
|
+
* deployments. This is enough for the first runtime loop and can later be
|
|
20
|
+
* replaced with Redis or another shared backing store without changing the
|
|
21
|
+
* caller contract.
|
|
22
|
+
*/
|
|
23
|
+
export declare class InMemoryNonceStore implements NonceStore {
|
|
24
|
+
private readonly ttlSeconds;
|
|
25
|
+
private readonly nowEpochSeconds;
|
|
26
|
+
private readonly entries;
|
|
27
|
+
constructor(options?: InMemoryNonceStoreOptions);
|
|
28
|
+
tryUse(nonce: string, timestampEpochSeconds: number): boolean;
|
|
29
|
+
private evictExpired;
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
2
|
+
/**
|
|
3
|
+
* Small in-memory nonce store for local development and single-process
|
|
4
|
+
* deployments. This is enough for the first runtime loop and can later be
|
|
5
|
+
* replaced with Redis or another shared backing store without changing the
|
|
6
|
+
* caller contract.
|
|
7
|
+
*/
|
|
8
|
+
export class InMemoryNonceStore {
|
|
9
|
+
ttlSeconds;
|
|
10
|
+
nowEpochSeconds;
|
|
11
|
+
entries = new Map();
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
14
|
+
this.nowEpochSeconds = options.nowEpochSeconds ?? (() => Math.floor(Date.now() / 1000));
|
|
15
|
+
}
|
|
16
|
+
tryUse(nonce, timestampEpochSeconds) {
|
|
17
|
+
this.evictExpired();
|
|
18
|
+
if (this.entries.has(nonce)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
this.entries.set(nonce, timestampEpochSeconds);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
evictExpired() {
|
|
25
|
+
const cutoff = this.nowEpochSeconds() - this.ttlSeconds;
|
|
26
|
+
for (const [nonce, timestamp] of this.entries.entries()) {
|
|
27
|
+
if (timestamp < cutoff) {
|
|
28
|
+
this.entries.delete(nonce);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SignatureInput {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
nonce: string;
|
|
6
|
+
rawBody?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export declare function sha256Hex(rawBody?: string | null): string;
|
|
9
|
+
export declare function buildCanonicalString(input: SignatureInput): string;
|
|
10
|
+
export declare function signPayload(secret: string, input: SignatureInput): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
export function sha256Hex(rawBody) {
|
|
3
|
+
return createHash("sha256").update(rawBody ?? "", "utf8").digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function buildCanonicalString(input) {
|
|
6
|
+
// Keep canonicalization byte-for-byte aligned with the published protocol spec
|
|
7
|
+
// so every SDK and plugin implementation produces the same signature.
|
|
8
|
+
return [
|
|
9
|
+
input.method.toUpperCase(),
|
|
10
|
+
input.path,
|
|
11
|
+
input.timestamp,
|
|
12
|
+
input.nonce,
|
|
13
|
+
sha256Hex(input.rawBody)
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
export function signPayload(secret, input) {
|
|
17
|
+
return createHmac("sha256", secret)
|
|
18
|
+
.update(buildCanonicalString(input), "utf8")
|
|
19
|
+
.digest("hex");
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { signPayload } from "./signer.js";
|
|
3
|
+
function normalizeHex(value) {
|
|
4
|
+
if (!/^[0-9a-f]+$/i.test(value) || value.length % 2 !== 0) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return Buffer.from(value.toLowerCase(), "hex");
|
|
8
|
+
}
|
|
9
|
+
export function verifyPayload(expectedSignature, secret, input) {
|
|
10
|
+
const provided = normalizeHex(expectedSignature);
|
|
11
|
+
if (provided === null) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const computed = normalizeHex(signPayload(secret, input));
|
|
15
|
+
if (computed === null || provided.length !== computed.length) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// Avoid leaking signature match information through early-return timing.
|
|
19
|
+
return timingSafeEqual(provided, computed);
|
|
20
|
+
}
|