@evgenyy/lessinbox-channel 0.1.8 → 0.1.10
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 +54 -6
- package/dist/index.d.ts +310 -16
- package/dist/index.js +885 -42
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/skills/lessinbox/SKILL.md +14 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import Redis from "ioredis";
|
|
2
2
|
import WebSocket from "ws";
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
const DEFAULT_RECONNECT_MS = 1_500;
|
|
4
5
|
const MIN_RECONNECT_MS = 250;
|
|
6
|
+
const INBOUND_MESSAGE_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
5
7
|
const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "canceled"]);
|
|
8
|
+
const ACTIVE_RUN_STATUSES = new Set(["queued", "running", "waiting_for_human"]);
|
|
6
9
|
function normalizeBaseUrl(baseUrl) {
|
|
7
10
|
return baseUrl.replace(/\/+$/, "");
|
|
8
11
|
}
|
|
@@ -26,6 +29,55 @@ function randomIdempotencyKey() {
|
|
|
26
29
|
}
|
|
27
30
|
return `li_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
28
31
|
}
|
|
32
|
+
function normalizeToCanonicalValue(value) {
|
|
33
|
+
if (value === null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const valueType = typeof value;
|
|
37
|
+
if (valueType === "string" || valueType === "boolean") {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
if (valueType === "number") {
|
|
41
|
+
const numeric = value;
|
|
42
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
43
|
+
}
|
|
44
|
+
if (value instanceof Date) {
|
|
45
|
+
return value.toISOString();
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((entry) => normalizeToCanonicalValue(entry));
|
|
49
|
+
}
|
|
50
|
+
if (valueType === "object") {
|
|
51
|
+
const record = value;
|
|
52
|
+
const keys = Object.keys(record).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
53
|
+
const normalized = {};
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
const entry = record[key];
|
|
56
|
+
if (entry === undefined || typeof entry === "function" || typeof entry === "symbol") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
normalized[key] = normalizeToCanonicalValue(entry);
|
|
60
|
+
}
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function canonicalizeJson(value) {
|
|
66
|
+
return JSON.stringify(normalizeToCanonicalValue(value));
|
|
67
|
+
}
|
|
68
|
+
function hashCanonicalJson(value, algorithm = "sha256") {
|
|
69
|
+
const canonicalJson = canonicalizeJson(value);
|
|
70
|
+
const digest = createHash(algorithm).update(canonicalJson).digest("hex");
|
|
71
|
+
return `${algorithm}:${digest}`;
|
|
72
|
+
}
|
|
73
|
+
export function hashActionPayload(input) {
|
|
74
|
+
return hashCanonicalJson({
|
|
75
|
+
tool: input.tool,
|
|
76
|
+
action: input.action,
|
|
77
|
+
request: input.request ?? null,
|
|
78
|
+
side_effects: input.side_effects ?? null
|
|
79
|
+
}, "sha256");
|
|
80
|
+
}
|
|
29
81
|
function safeJsonParse(value) {
|
|
30
82
|
try {
|
|
31
83
|
return JSON.parse(value);
|
|
@@ -91,10 +143,20 @@ function parseRunRef(value) {
|
|
|
91
143
|
const threadId = toOptionalString(record.threadId);
|
|
92
144
|
const runId = toOptionalString(record.runId);
|
|
93
145
|
const status = toOptionalString(record.status);
|
|
94
|
-
if (!threadId
|
|
146
|
+
if (!threadId) {
|
|
95
147
|
return undefined;
|
|
96
148
|
}
|
|
97
|
-
return {
|
|
149
|
+
return {
|
|
150
|
+
threadId,
|
|
151
|
+
...(runId ? { runId } : {}),
|
|
152
|
+
...(status ? { status } : {})
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function isTerminalRunStatus(status) {
|
|
156
|
+
return Boolean(status && TERMINAL_RUN_STATUSES.has(status));
|
|
157
|
+
}
|
|
158
|
+
function isActiveRunStatus(status) {
|
|
159
|
+
return Boolean(status && ACTIVE_RUN_STATUSES.has(status));
|
|
98
160
|
}
|
|
99
161
|
function extractRunId(payload) {
|
|
100
162
|
const record = asRecord(payload);
|
|
@@ -148,19 +210,19 @@ function buildWorkspaceWsEndpoint(apiUrl, wsUrlOverride) {
|
|
|
148
210
|
url.search = "";
|
|
149
211
|
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
|
150
212
|
if (!normalizedPath || normalizedPath === "/") {
|
|
151
|
-
url.pathname = "/
|
|
213
|
+
url.pathname = "/v2/ws";
|
|
152
214
|
return url.toString();
|
|
153
215
|
}
|
|
154
|
-
if (normalizedPath.endsWith("/
|
|
216
|
+
if (normalizedPath.endsWith("/v1")) {
|
|
155
217
|
const prefix = normalizedPath.slice(0, -3);
|
|
156
|
-
url.pathname = `${prefix || ""}/
|
|
218
|
+
url.pathname = `${prefix || ""}/v2/ws`;
|
|
157
219
|
return url.toString();
|
|
158
220
|
}
|
|
159
|
-
if (normalizedPath.endsWith("/
|
|
221
|
+
if (normalizedPath.endsWith("/v2")) {
|
|
160
222
|
url.pathname = `${normalizedPath}/ws`;
|
|
161
223
|
return url.toString();
|
|
162
224
|
}
|
|
163
|
-
url.pathname = `${normalizedPath}/
|
|
225
|
+
url.pathname = `${normalizedPath}/v2/ws`;
|
|
164
226
|
return url.toString();
|
|
165
227
|
}
|
|
166
228
|
function buildWorkspaceWsUrl(apiUrl, token, wsUrlOverride) {
|
|
@@ -389,14 +451,40 @@ class WorkspaceStreamConsumer {
|
|
|
389
451
|
}
|
|
390
452
|
const payload = asRecord(event.payload);
|
|
391
453
|
const toStatus = toOptionalString(payload?.to);
|
|
392
|
-
|
|
454
|
+
const mappedConversationId = await this.options.mappingStore.getConversationKeyForRun(event.runId);
|
|
455
|
+
const conversationId = event.conversationId ?? mappedConversationId ?? (event.threadId ? `thread:${event.threadId}` : undefined);
|
|
456
|
+
if (!conversationId) {
|
|
457
|
+
if (isTerminalRunStatus(toStatus)) {
|
|
458
|
+
await this.options.mappingStore.deleteRun(event.runId);
|
|
459
|
+
}
|
|
393
460
|
return;
|
|
394
461
|
}
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
462
|
+
const existing = await this.options.mappingStore.getConversationRun(conversationId);
|
|
463
|
+
const threadId = event.threadId ?? existing?.threadId;
|
|
464
|
+
if (!threadId) {
|
|
465
|
+
if (isTerminalRunStatus(toStatus)) {
|
|
466
|
+
await this.options.mappingStore.deleteRun(event.runId);
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
398
469
|
}
|
|
399
|
-
|
|
470
|
+
const resolvedStatus = toStatus ?? existing?.status;
|
|
471
|
+
if (isTerminalRunStatus(toStatus)) {
|
|
472
|
+
await this.options.mappingStore.setConversationRun(conversationId, {
|
|
473
|
+
threadId,
|
|
474
|
+
...(resolvedStatus ? { status: resolvedStatus } : {})
|
|
475
|
+
});
|
|
476
|
+
await this.options.mappingStore.deleteRun(event.runId);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (existing?.runId && existing.runId !== event.runId) {
|
|
480
|
+
await this.options.mappingStore.deleteRun(existing.runId);
|
|
481
|
+
}
|
|
482
|
+
await this.options.mappingStore.setConversationRun(conversationId, {
|
|
483
|
+
threadId,
|
|
484
|
+
runId: event.runId,
|
|
485
|
+
...(resolvedStatus ? { status: resolvedStatus } : {})
|
|
486
|
+
});
|
|
487
|
+
await this.options.mappingStore.setConversationKeyForRun(event.runId, conversationId);
|
|
400
488
|
}
|
|
401
489
|
async emit(event) {
|
|
402
490
|
for (const listener of this.listeners) {
|
|
@@ -453,6 +541,10 @@ function ensureWorkspaceConsumer(accountId, account, api, mappingStore) {
|
|
|
453
541
|
return consumer;
|
|
454
542
|
}
|
|
455
543
|
export async function shutdownLessinboxPluginResources() {
|
|
544
|
+
for (const unsubscribe of gatewayUnsubscribers.values()) {
|
|
545
|
+
unsubscribe();
|
|
546
|
+
}
|
|
547
|
+
gatewayUnsubscribers.clear();
|
|
456
548
|
for (const consumer of workspaceConsumers.values()) {
|
|
457
549
|
consumer.stop();
|
|
458
550
|
}
|
|
@@ -461,6 +553,8 @@ export async function shutdownLessinboxPluginResources() {
|
|
|
461
553
|
await store.close();
|
|
462
554
|
}
|
|
463
555
|
mappingStores.clear();
|
|
556
|
+
handledInboundMessages.clear();
|
|
557
|
+
lessinboxPluginRuntime = null;
|
|
464
558
|
}
|
|
465
559
|
export class LessinboxApi {
|
|
466
560
|
constructor(config) {
|
|
@@ -510,6 +604,41 @@ export class LessinboxApi {
|
|
|
510
604
|
idempotencyKey: randomIdempotencyKey()
|
|
511
605
|
});
|
|
512
606
|
}
|
|
607
|
+
async evaluateGuardrails(input) {
|
|
608
|
+
return this.request("/v2/guardrails/evaluate", {
|
|
609
|
+
method: "POST",
|
|
610
|
+
body: input,
|
|
611
|
+
idempotencyKey: randomIdempotencyKey()
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
async createSimulation(input) {
|
|
615
|
+
return this.request("/v2/simulate", {
|
|
616
|
+
method: "POST",
|
|
617
|
+
body: input,
|
|
618
|
+
idempotencyKey: randomIdempotencyKey()
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
async createApproval(input) {
|
|
622
|
+
return this.request("/v2/approvals", {
|
|
623
|
+
method: "POST",
|
|
624
|
+
body: input,
|
|
625
|
+
idempotencyKey: randomIdempotencyKey()
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
async decideApproval(approvalRequestId, input) {
|
|
629
|
+
return this.request(`/v2/approvals/${approvalRequestId}/decide`, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
body: input,
|
|
632
|
+
idempotencyKey: randomIdempotencyKey()
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
async createExecution(input) {
|
|
636
|
+
return this.request("/v2/executions", {
|
|
637
|
+
method: "POST",
|
|
638
|
+
body: input,
|
|
639
|
+
idempotencyKey: randomIdempotencyKey()
|
|
640
|
+
});
|
|
641
|
+
}
|
|
513
642
|
async completeRun(runId, summary) {
|
|
514
643
|
await this.request(`/v2/runs/${runId}/complete`, {
|
|
515
644
|
method: "POST",
|
|
@@ -524,6 +653,12 @@ export class LessinboxApi {
|
|
|
524
653
|
idempotencyKey: randomIdempotencyKey()
|
|
525
654
|
});
|
|
526
655
|
}
|
|
656
|
+
async getRun(runId) {
|
|
657
|
+
return this.request(`/v2/runs/${runId}`, { method: "GET" });
|
|
658
|
+
}
|
|
659
|
+
async getThread(threadId) {
|
|
660
|
+
return this.request(`/v2/threads/${threadId}`, { method: "GET" });
|
|
661
|
+
}
|
|
527
662
|
async listThreads(input = {}) {
|
|
528
663
|
const params = new URLSearchParams();
|
|
529
664
|
if (input.bucket)
|
|
@@ -538,11 +673,22 @@ export class LessinboxApi {
|
|
|
538
673
|
return this.request(`/v2/threads${query ? `?${query}` : ""}`, { method: "GET" });
|
|
539
674
|
}
|
|
540
675
|
async createWorkspaceWsToken() {
|
|
541
|
-
return this.request("/
|
|
676
|
+
return this.request("/v2/workspace/ws-token", {
|
|
542
677
|
method: "POST",
|
|
543
678
|
body: {}
|
|
544
679
|
});
|
|
545
680
|
}
|
|
681
|
+
async getThreadFeed(input) {
|
|
682
|
+
const params = new URLSearchParams();
|
|
683
|
+
if (typeof input.limit === "number") {
|
|
684
|
+
params.set("limit", String(input.limit));
|
|
685
|
+
}
|
|
686
|
+
if (input.cursor) {
|
|
687
|
+
params.set("cursor", input.cursor);
|
|
688
|
+
}
|
|
689
|
+
const query = params.toString();
|
|
690
|
+
return this.request(`/v2/threads/${input.threadId}/feed${query ? `?${query}` : ""}`, { method: "GET" });
|
|
691
|
+
}
|
|
546
692
|
async request(path, options) {
|
|
547
693
|
const headers = new Headers({
|
|
548
694
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -598,6 +744,7 @@ export function resolveAccountConfig(config, accountId) {
|
|
|
598
744
|
}
|
|
599
745
|
const mappingStore = resolveMappingStoreKind(account.mappingStore);
|
|
600
746
|
return {
|
|
747
|
+
accountId: resolvedId,
|
|
601
748
|
enabled: typeof account.enabled === "boolean" ? account.enabled : true,
|
|
602
749
|
apiUrl,
|
|
603
750
|
apiKey,
|
|
@@ -609,6 +756,219 @@ export function resolveAccountConfig(config, accountId) {
|
|
|
609
756
|
workspaceStream: resolveWorkspaceStreamConfig(account.workspaceStream)
|
|
610
757
|
};
|
|
611
758
|
}
|
|
759
|
+
export function createLessinboxApiFromConfig(input) {
|
|
760
|
+
const resolvedAccountId = deriveAccountRuntimeId(input.accountId);
|
|
761
|
+
const account = resolveAccountConfig(input.config, resolvedAccountId);
|
|
762
|
+
if (account.enabled === false) {
|
|
763
|
+
throw new Error(`Lessinbox account '${resolvedAccountId}' is disabled`);
|
|
764
|
+
}
|
|
765
|
+
const api = new LessinboxApi(account);
|
|
766
|
+
return {
|
|
767
|
+
accountId: resolvedAccountId,
|
|
768
|
+
account,
|
|
769
|
+
api
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
function toExecutionErrorPayload(err) {
|
|
773
|
+
if (err instanceof Error) {
|
|
774
|
+
const details = {};
|
|
775
|
+
if (typeof err.stack === "string") {
|
|
776
|
+
details.stack = err.stack;
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
kind: "execution_error",
|
|
780
|
+
message: err.message || "Execution failed",
|
|
781
|
+
...(Object.keys(details).length > 0 ? { details } : {})
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
if (typeof err === "string") {
|
|
785
|
+
return {
|
|
786
|
+
kind: "execution_error",
|
|
787
|
+
message: err
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
kind: "execution_error",
|
|
792
|
+
message: "Execution failed",
|
|
793
|
+
details: {
|
|
794
|
+
value: err
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function buildGuardedExecutionMetadata(input) {
|
|
799
|
+
return {
|
|
800
|
+
...(input.metadata ?? {}),
|
|
801
|
+
action_hash: input.actionHash,
|
|
802
|
+
...(input.approval ? { approval_request_id: input.approval.approval_request_id } : {}),
|
|
803
|
+
...(input.simulation
|
|
804
|
+
? {
|
|
805
|
+
simulation_id: input.simulation.simulation_id,
|
|
806
|
+
simulation_hash: input.simulation.simulation_hash
|
|
807
|
+
}
|
|
808
|
+
: {})
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
export async function executeLessinboxGuardedExecution(input) {
|
|
812
|
+
const { api } = createLessinboxApiFromConfig({
|
|
813
|
+
config: input.config,
|
|
814
|
+
accountId: input.accountId
|
|
815
|
+
});
|
|
816
|
+
const scope = input.scope ?? {};
|
|
817
|
+
const actionHash = hashActionPayload({
|
|
818
|
+
tool: input.tool,
|
|
819
|
+
action: input.action,
|
|
820
|
+
request: input.request,
|
|
821
|
+
side_effects: input.side_effects
|
|
822
|
+
});
|
|
823
|
+
const guardrails = await api.evaluateGuardrails({
|
|
824
|
+
tool: input.tool,
|
|
825
|
+
action: input.action,
|
|
826
|
+
request: input.request,
|
|
827
|
+
side_effects: input.side_effects,
|
|
828
|
+
metadata: input.metadata,
|
|
829
|
+
action_hash: actionHash
|
|
830
|
+
});
|
|
831
|
+
if (guardrails.decision === "deny") {
|
|
832
|
+
throw new Error(`Execution blocked by guardrails: ${guardrails.reason}`);
|
|
833
|
+
}
|
|
834
|
+
let simulation;
|
|
835
|
+
if (guardrails.decision === "require_simulation") {
|
|
836
|
+
simulation = await api.createSimulation({
|
|
837
|
+
work_item_id: scope.work_item_id ?? null,
|
|
838
|
+
run_id: scope.run_id ?? null,
|
|
839
|
+
thread_id: scope.thread_id ?? null,
|
|
840
|
+
mode: input.simulation_mode,
|
|
841
|
+
tool: input.tool,
|
|
842
|
+
action: input.action,
|
|
843
|
+
request: input.request,
|
|
844
|
+
side_effects: input.side_effects,
|
|
845
|
+
metadata: input.metadata,
|
|
846
|
+
action_hash: actionHash
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
let approval;
|
|
850
|
+
if (guardrails.decision === "require_approval") {
|
|
851
|
+
approval = await api.createApproval({
|
|
852
|
+
work_item_id: scope.work_item_id ?? null,
|
|
853
|
+
run_id: scope.run_id ?? null,
|
|
854
|
+
thread_id: scope.thread_id ?? null,
|
|
855
|
+
tool: input.tool,
|
|
856
|
+
action: input.action,
|
|
857
|
+
request: input.request,
|
|
858
|
+
side_effects: input.side_effects,
|
|
859
|
+
simulation_id: simulation?.simulation_id ?? null,
|
|
860
|
+
simulation_hash: simulation?.simulation_hash ?? null,
|
|
861
|
+
reason: input.approval_reason,
|
|
862
|
+
metadata: input.metadata,
|
|
863
|
+
action_hash: actionHash
|
|
864
|
+
});
|
|
865
|
+
if (input.onApprovalRequired) {
|
|
866
|
+
await input.onApprovalRequired({
|
|
867
|
+
approval_request_id: approval.approval_request_id,
|
|
868
|
+
action_hash: actionHash
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const executionMetadata = buildGuardedExecutionMetadata({
|
|
873
|
+
metadata: input.metadata,
|
|
874
|
+
actionHash,
|
|
875
|
+
approval,
|
|
876
|
+
simulation
|
|
877
|
+
});
|
|
878
|
+
const preflightExecution = await api.createExecution({
|
|
879
|
+
work_item_id: scope.work_item_id ?? null,
|
|
880
|
+
run_id: scope.run_id ?? null,
|
|
881
|
+
thread_id: scope.thread_id ?? null,
|
|
882
|
+
tool: input.tool,
|
|
883
|
+
action: input.action,
|
|
884
|
+
request: input.request,
|
|
885
|
+
side_effects: input.side_effects,
|
|
886
|
+
status: "planned",
|
|
887
|
+
metadata: executionMetadata
|
|
888
|
+
});
|
|
889
|
+
try {
|
|
890
|
+
const result = await input.execute();
|
|
891
|
+
const finalExecution = await api.createExecution({
|
|
892
|
+
work_item_id: scope.work_item_id ?? null,
|
|
893
|
+
run_id: scope.run_id ?? null,
|
|
894
|
+
thread_id: scope.thread_id ?? null,
|
|
895
|
+
tool: input.tool,
|
|
896
|
+
action: input.action,
|
|
897
|
+
request: input.request,
|
|
898
|
+
response: result,
|
|
899
|
+
side_effects: input.side_effects,
|
|
900
|
+
status: "succeeded",
|
|
901
|
+
metadata: executionMetadata
|
|
902
|
+
});
|
|
903
|
+
return {
|
|
904
|
+
action_hash: actionHash,
|
|
905
|
+
guardrails,
|
|
906
|
+
simulation,
|
|
907
|
+
approval,
|
|
908
|
+
preflight_execution: preflightExecution,
|
|
909
|
+
final_execution: finalExecution,
|
|
910
|
+
result
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
await api.createExecution({
|
|
915
|
+
work_item_id: scope.work_item_id ?? null,
|
|
916
|
+
run_id: scope.run_id ?? null,
|
|
917
|
+
thread_id: scope.thread_id ?? null,
|
|
918
|
+
tool: input.tool,
|
|
919
|
+
action: input.action,
|
|
920
|
+
request: input.request,
|
|
921
|
+
side_effects: input.side_effects,
|
|
922
|
+
status: "failed",
|
|
923
|
+
error: toExecutionErrorPayload(err),
|
|
924
|
+
metadata: executionMetadata
|
|
925
|
+
});
|
|
926
|
+
throw err;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
const gatewayUnsubscribers = new Map();
|
|
930
|
+
const handledInboundMessages = new Map();
|
|
931
|
+
let lessinboxPluginRuntime = null;
|
|
932
|
+
function setLessinboxPluginRuntime(runtime) {
|
|
933
|
+
if (!runtime || typeof runtime !== "object") {
|
|
934
|
+
lessinboxPluginRuntime = null;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
lessinboxPluginRuntime = runtime;
|
|
938
|
+
}
|
|
939
|
+
function getLessinboxPluginRuntime() {
|
|
940
|
+
return lessinboxPluginRuntime;
|
|
941
|
+
}
|
|
942
|
+
function formatError(err) {
|
|
943
|
+
if (err instanceof Error && err.message) {
|
|
944
|
+
return err.message;
|
|
945
|
+
}
|
|
946
|
+
if (typeof err === "string") {
|
|
947
|
+
return err;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
return JSON.stringify(err);
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
return String(err);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function sleep(ms) {
|
|
957
|
+
return new Promise((resolve) => {
|
|
958
|
+
setTimeout(resolve, ms);
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function toOptionalTimestamp(value) {
|
|
962
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
963
|
+
return value;
|
|
964
|
+
}
|
|
965
|
+
const asString = toOptionalString(value);
|
|
966
|
+
if (!asString) {
|
|
967
|
+
return undefined;
|
|
968
|
+
}
|
|
969
|
+
const parsed = Date.parse(asString);
|
|
970
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
971
|
+
}
|
|
612
972
|
function deriveConversationKey(input) {
|
|
613
973
|
if (input.conversationId && input.conversationId.trim().length > 0) {
|
|
614
974
|
return input.conversationId;
|
|
@@ -669,6 +1029,136 @@ function resolveSendTarget(input) {
|
|
|
669
1029
|
// Raw IDs default to channel target so CLI `--target <channelId>` works out of the box.
|
|
670
1030
|
return { channelId: explicitTarget, conversationId: `channel:${explicitTarget}` };
|
|
671
1031
|
}
|
|
1032
|
+
function deriveChannelIdFromConversationKey(conversationKey) {
|
|
1033
|
+
if (!conversationKey || !conversationKey.startsWith("channel:")) {
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
const channelId = conversationKey.slice("channel:".length).trim();
|
|
1037
|
+
return channelId.length > 0 ? channelId : undefined;
|
|
1038
|
+
}
|
|
1039
|
+
function deriveTitle(input) {
|
|
1040
|
+
if (input.title && input.title.trim().length > 0) {
|
|
1041
|
+
return input.title.trim();
|
|
1042
|
+
}
|
|
1043
|
+
return input.text.slice(0, 80);
|
|
1044
|
+
}
|
|
1045
|
+
function toRunRefFromThreadSummary(summary) {
|
|
1046
|
+
const runId = toOptionalString(summary.session_id) ?? toOptionalString(summary.run_id);
|
|
1047
|
+
const status = toOptionalString(summary.session_status) ?? toOptionalString(summary.run_status);
|
|
1048
|
+
return {
|
|
1049
|
+
threadId: summary.id,
|
|
1050
|
+
...(runId ? { runId } : {}),
|
|
1051
|
+
...(status ? { status } : {})
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
function toRunRefFromThreadDetail(thread) {
|
|
1055
|
+
const session = asRecord(thread.session);
|
|
1056
|
+
const run = asRecord(thread.run);
|
|
1057
|
+
const runId = toOptionalString(session?.id) ?? toOptionalString(run?.id);
|
|
1058
|
+
const status = toOptionalString(session?.status) ?? toOptionalString(run?.status);
|
|
1059
|
+
return {
|
|
1060
|
+
threadId: thread.id,
|
|
1061
|
+
...(runId ? { runId } : {}),
|
|
1062
|
+
...(status ? { status } : {})
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function normalizeConversationMetadata(metadata, conversationKey) {
|
|
1066
|
+
if (!conversationKey) {
|
|
1067
|
+
return metadata;
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
...(metadata ?? {}),
|
|
1071
|
+
conversation_id: conversationKey
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
function purgeHandledInboundMessages(now) {
|
|
1075
|
+
for (const [key, expiresAt] of handledInboundMessages.entries()) {
|
|
1076
|
+
if (expiresAt <= now) {
|
|
1077
|
+
handledInboundMessages.delete(key);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function hasHandledInboundMessage(key) {
|
|
1082
|
+
const now = Date.now();
|
|
1083
|
+
purgeHandledInboundMessages(now);
|
|
1084
|
+
const expiresAt = handledInboundMessages.get(key);
|
|
1085
|
+
return typeof expiresAt === "number" && expiresAt > now;
|
|
1086
|
+
}
|
|
1087
|
+
function markHandledInboundMessage(key) {
|
|
1088
|
+
handledInboundMessages.set(key, Date.now() + INBOUND_MESSAGE_DEDUPE_TTL_MS);
|
|
1089
|
+
}
|
|
1090
|
+
function deriveHandledInboundKey(accountId, messageId) {
|
|
1091
|
+
return `${accountId}:${messageId}`;
|
|
1092
|
+
}
|
|
1093
|
+
function resolveMessageActorType(message) {
|
|
1094
|
+
const actor = asRecord(message?.actor);
|
|
1095
|
+
return toOptionalString(actor?.type) ?? "unknown";
|
|
1096
|
+
}
|
|
1097
|
+
function resolveMessageActorUserId(message) {
|
|
1098
|
+
const actor = asRecord(message?.actor);
|
|
1099
|
+
return toOptionalString(actor?.user_id) ?? toOptionalString(actor?.userId);
|
|
1100
|
+
}
|
|
1101
|
+
function resolveEventPayloadMessage(event) {
|
|
1102
|
+
const payload = asRecord(event.payload);
|
|
1103
|
+
const message = asRecord(payload?.message);
|
|
1104
|
+
const id = toOptionalString(message?.id);
|
|
1105
|
+
if (!id) {
|
|
1106
|
+
return undefined;
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
id,
|
|
1110
|
+
run_id: toOptionalString(message?.run_id) ?? undefined,
|
|
1111
|
+
session_id: toOptionalString(message?.session_id) ?? undefined,
|
|
1112
|
+
kind: toOptionalString(message?.kind),
|
|
1113
|
+
text: toOptionalString(message?.text),
|
|
1114
|
+
actor: asRecord(message?.actor),
|
|
1115
|
+
created_at: toOptionalString(message?.created_at)
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async function findFeedMessageById(api, threadId, messageId) {
|
|
1119
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1120
|
+
try {
|
|
1121
|
+
const feed = await api.getThreadFeed({ threadId, limit: 50 });
|
|
1122
|
+
const found = feed.entries.find((entry) => toOptionalString(entry?.message?.id) === messageId)?.message;
|
|
1123
|
+
if (found) {
|
|
1124
|
+
return found;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
// ignore and retry
|
|
1129
|
+
}
|
|
1130
|
+
if (attempt < 2) {
|
|
1131
|
+
await sleep((attempt + 1) * 120);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return undefined;
|
|
1135
|
+
}
|
|
1136
|
+
function coalesceInboundMessage(messageId, payloadMessage, feedMessage) {
|
|
1137
|
+
const text = toOptionalString(feedMessage?.text) ??
|
|
1138
|
+
toOptionalString(payloadMessage?.text);
|
|
1139
|
+
if (!text) {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
const actorType = resolveMessageActorType(feedMessage) !== "unknown"
|
|
1143
|
+
? resolveMessageActorType(feedMessage)
|
|
1144
|
+
: resolveMessageActorType(payloadMessage);
|
|
1145
|
+
const actorUserId = resolveMessageActorUserId(feedMessage) ??
|
|
1146
|
+
resolveMessageActorUserId(payloadMessage);
|
|
1147
|
+
const runId = toOptionalString(feedMessage?.run_id) ??
|
|
1148
|
+
toOptionalString(feedMessage?.session_id) ??
|
|
1149
|
+
toOptionalString(payloadMessage?.run_id) ??
|
|
1150
|
+
toOptionalString(payloadMessage?.session_id);
|
|
1151
|
+
const createdAtMs = toOptionalTimestamp(feedMessage?.created_at) ??
|
|
1152
|
+
toOptionalTimestamp(payloadMessage?.created_at);
|
|
1153
|
+
return {
|
|
1154
|
+
id: messageId,
|
|
1155
|
+
text,
|
|
1156
|
+
actorType,
|
|
1157
|
+
...(actorUserId ? { actorUserId } : {}),
|
|
1158
|
+
...(runId ? { runId } : {}),
|
|
1159
|
+
...(typeof createdAtMs === "number" ? { createdAtMs } : {})
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
672
1162
|
async function sendTextToLessinbox(input) {
|
|
673
1163
|
if (!input.config) {
|
|
674
1164
|
throw new Error("Plugin sendText did not receive runtime config");
|
|
@@ -694,37 +1184,368 @@ async function sendTextToLessinbox(input) {
|
|
|
694
1184
|
const mappedRun = conversationKey ? await mappingStore.getConversationRun(conversationKey) : undefined;
|
|
695
1185
|
let threadId = resolvedThreadId ?? mappedRun?.threadId;
|
|
696
1186
|
let runId = resolvedRunId ?? mappedRun?.runId;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1187
|
+
let runStatus = mappedRun?.status;
|
|
1188
|
+
let channelId = resolvedChannelId ?? deriveChannelIdFromConversationKey(conversationKey);
|
|
1189
|
+
// Recover latest known thread for channel-targeted conversations when in-memory mapping is empty.
|
|
1190
|
+
if (!threadId && !runId && channelId) {
|
|
1191
|
+
const listing = await api.listThreads({
|
|
1192
|
+
bucket: "all",
|
|
1193
|
+
channelId,
|
|
1194
|
+
limit: 1
|
|
1195
|
+
});
|
|
1196
|
+
const latest = listing.threads[0];
|
|
1197
|
+
if (latest) {
|
|
1198
|
+
const recovered = toRunRefFromThreadSummary(latest);
|
|
1199
|
+
threadId = recovered.threadId;
|
|
1200
|
+
runId = recovered.runId;
|
|
1201
|
+
runStatus = recovered.status;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
// Thread details give us canonical channel and latest session snapshot.
|
|
1205
|
+
if (threadId && (!channelId || !runId || !runStatus)) {
|
|
1206
|
+
const thread = await api.getThread(threadId);
|
|
1207
|
+
channelId = channelId ?? thread.channel_id;
|
|
1208
|
+
const detailRef = toRunRefFromThreadDetail(thread);
|
|
1209
|
+
if (!runId && detailRef.runId) {
|
|
1210
|
+
runId = detailRef.runId;
|
|
1211
|
+
}
|
|
1212
|
+
if (!runStatus && detailRef.status) {
|
|
1213
|
+
runStatus = detailRef.status;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// For mapped runs, verify status so terminal sessions rotate automatically.
|
|
1217
|
+
if (runId && !resolvedRunId) {
|
|
1218
|
+
try {
|
|
1219
|
+
const runSnapshot = await api.getRun(runId);
|
|
1220
|
+
const run = runSnapshot.run;
|
|
1221
|
+
runStatus = toOptionalString(run.status) ?? runStatus;
|
|
1222
|
+
if (!threadId) {
|
|
1223
|
+
threadId = toOptionalString(run.thread_id) ?? threadId;
|
|
1224
|
+
}
|
|
1225
|
+
channelId = channelId ?? toOptionalString(run.channel_id);
|
|
1226
|
+
}
|
|
1227
|
+
catch {
|
|
1228
|
+
runId = undefined;
|
|
1229
|
+
runStatus = undefined;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (runId && !resolvedRunId && !isActiveRunStatus(runStatus)) {
|
|
1233
|
+
await mappingStore.deleteRun(runId);
|
|
1234
|
+
runId = undefined;
|
|
1235
|
+
}
|
|
1236
|
+
if (!runId) {
|
|
700
1237
|
const started = await api.startRun({
|
|
701
|
-
title,
|
|
702
|
-
channelId:
|
|
1238
|
+
title: deriveTitle(input),
|
|
1239
|
+
channelId: channelId ?? api.getDefaultChannelId(),
|
|
703
1240
|
threadId,
|
|
704
|
-
metadata: input.metadata
|
|
1241
|
+
metadata: normalizeConversationMetadata(input.metadata, conversationKey)
|
|
705
1242
|
});
|
|
706
1243
|
threadId = started.thread_id;
|
|
707
1244
|
runId = started.run_id;
|
|
1245
|
+
runStatus = started.session_status ?? started.status;
|
|
1246
|
+
channelId = channelId ?? api.getDefaultChannelId();
|
|
1247
|
+
}
|
|
1248
|
+
if (!threadId) {
|
|
1249
|
+
throw new Error("Unable to resolve Lessinbox thread for outbound message");
|
|
708
1250
|
}
|
|
709
|
-
await api.postMessage({
|
|
1251
|
+
const message = await api.postMessage({
|
|
710
1252
|
threadId,
|
|
711
1253
|
runId,
|
|
712
1254
|
text: input.text,
|
|
713
1255
|
kind: "text"
|
|
714
1256
|
});
|
|
1257
|
+
const resolvedMessageRunId = message.run_id ?? runId;
|
|
715
1258
|
if (conversationKey) {
|
|
716
|
-
if (mappedRun?.runId && mappedRun.runId !==
|
|
1259
|
+
if (mappedRun?.runId && mappedRun.runId !== resolvedMessageRunId) {
|
|
717
1260
|
await mappingStore.deleteRun(mappedRun.runId);
|
|
718
1261
|
}
|
|
719
|
-
await mappingStore.setConversationRun(conversationKey, {
|
|
720
|
-
|
|
1262
|
+
await mappingStore.setConversationRun(conversationKey, {
|
|
1263
|
+
threadId,
|
|
1264
|
+
...(resolvedMessageRunId ? { runId: resolvedMessageRunId } : {}),
|
|
1265
|
+
...(runStatus ? { status: runStatus } : {})
|
|
1266
|
+
});
|
|
1267
|
+
if (resolvedMessageRunId) {
|
|
1268
|
+
await mappingStore.setConversationKeyForRun(resolvedMessageRunId, conversationKey);
|
|
1269
|
+
}
|
|
721
1270
|
}
|
|
722
1271
|
return {
|
|
723
1272
|
ok: true,
|
|
724
1273
|
threadId,
|
|
725
|
-
runId
|
|
1274
|
+
runId: resolvedMessageRunId ?? runId
|
|
726
1275
|
};
|
|
727
1276
|
}
|
|
1277
|
+
async function sendMediaToLessinbox(input) {
|
|
1278
|
+
const mediaUrl = toOptionalString(input.mediaUrl);
|
|
1279
|
+
const caption = input.text?.trim() ?? "";
|
|
1280
|
+
if (!mediaUrl) {
|
|
1281
|
+
return sendTextToLessinbox({
|
|
1282
|
+
...input,
|
|
1283
|
+
text: caption
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
const messageText = [caption, `Attachment: ${mediaUrl}`].filter(Boolean).join("\n\n");
|
|
1287
|
+
return sendTextToLessinbox({
|
|
1288
|
+
...input,
|
|
1289
|
+
text: messageText || `Attachment: ${mediaUrl}`
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
async function sendReplyPayloadToLessinbox(input) {
|
|
1293
|
+
const mediaUrls = input.payload.mediaUrls?.length
|
|
1294
|
+
? input.payload.mediaUrls
|
|
1295
|
+
: input.payload.mediaUrl
|
|
1296
|
+
? [input.payload.mediaUrl]
|
|
1297
|
+
: [];
|
|
1298
|
+
if (mediaUrls.length === 0) {
|
|
1299
|
+
await sendTextToLessinbox({
|
|
1300
|
+
text: input.payload.text ?? "",
|
|
1301
|
+
accountId: input.accountId,
|
|
1302
|
+
config: input.config,
|
|
1303
|
+
threadId: input.threadId,
|
|
1304
|
+
runId: input.runId,
|
|
1305
|
+
conversationId: `thread:${input.threadId}`
|
|
1306
|
+
});
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
let first = true;
|
|
1310
|
+
for (const mediaUrl of mediaUrls) {
|
|
1311
|
+
await sendMediaToLessinbox({
|
|
1312
|
+
text: first ? input.payload.text ?? "" : "",
|
|
1313
|
+
mediaUrl,
|
|
1314
|
+
accountId: input.accountId,
|
|
1315
|
+
config: input.config,
|
|
1316
|
+
threadId: input.threadId,
|
|
1317
|
+
runId: input.runId,
|
|
1318
|
+
conversationId: `thread:${input.threadId}`
|
|
1319
|
+
});
|
|
1320
|
+
first = false;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async function dispatchInboundMessageToRuntime(input) {
|
|
1324
|
+
const routing = input.runtime.channel?.routing?.resolveAgentRoute;
|
|
1325
|
+
const replyRuntime = input.runtime.channel?.reply;
|
|
1326
|
+
if (!routing || !replyRuntime?.finalizeInboundContext || !replyRuntime.dispatchReplyWithBufferedBlockDispatcher) {
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const route = routing({
|
|
1330
|
+
cfg: input.cfg,
|
|
1331
|
+
channel: "lessinbox",
|
|
1332
|
+
accountId: input.accountId,
|
|
1333
|
+
peer: {
|
|
1334
|
+
kind: "direct",
|
|
1335
|
+
id: input.threadId
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
const routeSessionKey = toOptionalString(route.sessionKey) ??
|
|
1339
|
+
toOptionalString(route.mainSessionKey) ??
|
|
1340
|
+
`agent:main:lessinbox:thread:${input.threadId}`;
|
|
1341
|
+
const routeAccountId = toOptionalString(route.accountId) ?? input.accountId;
|
|
1342
|
+
const routeAgentId = toOptionalString(route.agentId);
|
|
1343
|
+
const ctxPayload = replyRuntime.finalizeInboundContext({
|
|
1344
|
+
Body: input.message.text,
|
|
1345
|
+
BodyForAgent: input.message.text,
|
|
1346
|
+
RawBody: input.message.text,
|
|
1347
|
+
CommandBody: input.message.text,
|
|
1348
|
+
From: `lessinbox:thread:${input.threadId}`,
|
|
1349
|
+
To: `thread:${input.threadId}`,
|
|
1350
|
+
SessionKey: routeSessionKey,
|
|
1351
|
+
AccountId: routeAccountId,
|
|
1352
|
+
ChatType: "direct",
|
|
1353
|
+
ConversationLabel: `Lessinbox thread ${input.threadId}`,
|
|
1354
|
+
SenderId: input.message.actorUserId ?? `thread:${input.threadId}`,
|
|
1355
|
+
Provider: "lessinbox",
|
|
1356
|
+
Surface: "lessinbox",
|
|
1357
|
+
MessageSid: input.message.id,
|
|
1358
|
+
Timestamp: input.message.createdAtMs ?? Date.now(),
|
|
1359
|
+
OriginatingChannel: "lessinbox",
|
|
1360
|
+
OriginatingTo: `thread:${input.threadId}`,
|
|
1361
|
+
CommandAuthorized: true
|
|
1362
|
+
});
|
|
1363
|
+
const sessionStore = asRecord(input.cfg)?.session;
|
|
1364
|
+
const resolveStorePath = input.runtime.channel?.session?.resolveStorePath;
|
|
1365
|
+
const recordInboundSession = input.runtime.channel?.session?.recordInboundSession;
|
|
1366
|
+
if (resolveStorePath && recordInboundSession) {
|
|
1367
|
+
const storePath = resolveStorePath(asRecord(sessionStore)?.store, { agentId: routeAgentId });
|
|
1368
|
+
await recordInboundSession({
|
|
1369
|
+
storePath,
|
|
1370
|
+
sessionKey: routeSessionKey,
|
|
1371
|
+
ctx: ctxPayload,
|
|
1372
|
+
onRecordError: (err) => {
|
|
1373
|
+
input.log?.error?.(`lessinbox failed recording inbound session metadata: ${formatError(err)}`);
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
input.runtime.system?.enqueueSystemEvent?.(`Lessinbox message in thread ${input.threadId}: ${input.message.text.slice(0, 160)}`, {
|
|
1378
|
+
sessionKey: routeSessionKey,
|
|
1379
|
+
contextKey: `lessinbox:message:${input.threadId}:${input.message.id}`
|
|
1380
|
+
});
|
|
1381
|
+
await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({
|
|
1382
|
+
ctx: ctxPayload,
|
|
1383
|
+
cfg: input.cfg,
|
|
1384
|
+
dispatcherOptions: {
|
|
1385
|
+
deliver: async (payload) => {
|
|
1386
|
+
await sendReplyPayloadToLessinbox({
|
|
1387
|
+
payload,
|
|
1388
|
+
accountId: input.accountId,
|
|
1389
|
+
config: input.cfg,
|
|
1390
|
+
threadId: input.threadId,
|
|
1391
|
+
runId: input.runId
|
|
1392
|
+
});
|
|
1393
|
+
input.runtime.channel?.activity?.record?.({
|
|
1394
|
+
channel: "lessinbox",
|
|
1395
|
+
accountId: input.accountId,
|
|
1396
|
+
direction: "outbound"
|
|
1397
|
+
});
|
|
1398
|
+
},
|
|
1399
|
+
onError: (err, info) => {
|
|
1400
|
+
input.log?.error?.(`lessinbox ${info.kind} reply failed: ${formatError(err)}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
async function handleGatewayInboundEvent(input) {
|
|
1406
|
+
if (input.event.kind !== "message.created") {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const threadId = input.event.threadId;
|
|
1410
|
+
if (!threadId) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const payloadMessage = resolveEventPayloadMessage(input.event);
|
|
1414
|
+
const messageId = toOptionalString(payloadMessage?.id);
|
|
1415
|
+
if (!messageId) {
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const handledKey = deriveHandledInboundKey(input.accountId, messageId);
|
|
1419
|
+
if (hasHandledInboundMessage(handledKey)) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const feedMessage = await findFeedMessageById(input.api, threadId, messageId);
|
|
1423
|
+
const inboundMessage = coalesceInboundMessage(messageId, payloadMessage, feedMessage);
|
|
1424
|
+
if (!inboundMessage) {
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (inboundMessage.actorType !== "user") {
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
markHandledInboundMessage(handledKey);
|
|
1431
|
+
const conversationId = input.event.conversationId ?? `thread:${threadId}`;
|
|
1432
|
+
const runId = inboundMessage.runId ?? input.event.runId;
|
|
1433
|
+
if (runId) {
|
|
1434
|
+
await input.mappingStore.setConversationRun(conversationId, {
|
|
1435
|
+
threadId,
|
|
1436
|
+
runId,
|
|
1437
|
+
status: "running"
|
|
1438
|
+
});
|
|
1439
|
+
await input.mappingStore.setConversationKeyForRun(runId, conversationId);
|
|
1440
|
+
}
|
|
1441
|
+
const at = inboundMessage.createdAtMs ?? Date.now();
|
|
1442
|
+
input.runtime.channel?.activity?.record?.({
|
|
1443
|
+
channel: "lessinbox",
|
|
1444
|
+
accountId: input.accountId,
|
|
1445
|
+
direction: "inbound",
|
|
1446
|
+
at
|
|
1447
|
+
});
|
|
1448
|
+
input.setStatus({
|
|
1449
|
+
accountId: input.accountId,
|
|
1450
|
+
connected: true,
|
|
1451
|
+
lastInboundAt: at,
|
|
1452
|
+
lastError: null
|
|
1453
|
+
});
|
|
1454
|
+
await dispatchInboundMessageToRuntime({
|
|
1455
|
+
runtime: input.runtime,
|
|
1456
|
+
cfg: input.cfg,
|
|
1457
|
+
accountId: input.accountId,
|
|
1458
|
+
threadId,
|
|
1459
|
+
runId,
|
|
1460
|
+
message: inboundMessage,
|
|
1461
|
+
log: input.log
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
function cleanupConsumerIfUnused(consumerKey) {
|
|
1465
|
+
const consumer = workspaceConsumers.get(consumerKey);
|
|
1466
|
+
if (!consumer) {
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (consumer.listenerCount() > 0) {
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
consumer.stop();
|
|
1473
|
+
workspaceConsumers.delete(consumerKey);
|
|
1474
|
+
}
|
|
1475
|
+
async function startLessinboxGatewayAccount(ctx) {
|
|
1476
|
+
const runtime = getLessinboxPluginRuntime();
|
|
1477
|
+
const accountId = deriveAccountRuntimeId(ctx.accountId);
|
|
1478
|
+
if (!runtime) {
|
|
1479
|
+
ctx.setStatus({
|
|
1480
|
+
accountId,
|
|
1481
|
+
running: false,
|
|
1482
|
+
connected: false,
|
|
1483
|
+
lastError: "Lessinbox plugin runtime not initialized"
|
|
1484
|
+
});
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (ctx.account.enabled === false) {
|
|
1488
|
+
ctx.setStatus({
|
|
1489
|
+
accountId,
|
|
1490
|
+
running: false,
|
|
1491
|
+
connected: false,
|
|
1492
|
+
lastError: "Lessinbox account is disabled"
|
|
1493
|
+
});
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const api = new LessinboxApi(ctx.account);
|
|
1497
|
+
const mappingStore = getConversationMappingStore(accountId, ctx.account);
|
|
1498
|
+
const consumer = ensureWorkspaceConsumer(accountId, ctx.account, api, mappingStore);
|
|
1499
|
+
const consumerKey = deriveWorkspaceConsumerRegistryKey(accountId, ctx.account);
|
|
1500
|
+
gatewayUnsubscribers.get(consumerKey)?.();
|
|
1501
|
+
gatewayUnsubscribers.delete(consumerKey);
|
|
1502
|
+
if (!consumer) {
|
|
1503
|
+
ctx.setStatus({
|
|
1504
|
+
accountId,
|
|
1505
|
+
running: false,
|
|
1506
|
+
connected: false,
|
|
1507
|
+
lastError: "Lessinbox workspace stream is disabled"
|
|
1508
|
+
});
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
const unsubscribe = consumer.addListener(async (event) => {
|
|
1512
|
+
await handleGatewayInboundEvent({
|
|
1513
|
+
runtime,
|
|
1514
|
+
cfg: ctx.cfg,
|
|
1515
|
+
api,
|
|
1516
|
+
accountId,
|
|
1517
|
+
mappingStore,
|
|
1518
|
+
event,
|
|
1519
|
+
setStatus: ctx.setStatus,
|
|
1520
|
+
log: ctx.log
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
gatewayUnsubscribers.set(consumerKey, unsubscribe);
|
|
1524
|
+
consumer.start();
|
|
1525
|
+
ctx.setStatus({
|
|
1526
|
+
accountId,
|
|
1527
|
+
running: true,
|
|
1528
|
+
connected: true,
|
|
1529
|
+
lastStartAt: Date.now(),
|
|
1530
|
+
lastError: null
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
async function stopLessinboxGatewayAccount(ctx) {
|
|
1534
|
+
const accountId = deriveAccountRuntimeId(ctx.accountId);
|
|
1535
|
+
const consumerKey = deriveWorkspaceConsumerRegistryKey(accountId, ctx.account);
|
|
1536
|
+
const unsubscribe = gatewayUnsubscribers.get(consumerKey);
|
|
1537
|
+
if (unsubscribe) {
|
|
1538
|
+
unsubscribe();
|
|
1539
|
+
gatewayUnsubscribers.delete(consumerKey);
|
|
1540
|
+
}
|
|
1541
|
+
cleanupConsumerIfUnused(consumerKey);
|
|
1542
|
+
ctx.setStatus({
|
|
1543
|
+
accountId,
|
|
1544
|
+
running: false,
|
|
1545
|
+
connected: false,
|
|
1546
|
+
lastStopAt: Date.now()
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
728
1549
|
export function subscribeToLessinboxEvents(input) {
|
|
729
1550
|
const accountRuntimeId = deriveAccountRuntimeId(input.accountId);
|
|
730
1551
|
const account = resolveAccountConfig(input.config, accountRuntimeId);
|
|
@@ -739,16 +1560,6 @@ export function subscribeToLessinboxEvents(input) {
|
|
|
739
1560
|
}
|
|
740
1561
|
return consumer.addListener(input.onEvent);
|
|
741
1562
|
}
|
|
742
|
-
function subscribeFromPlugin(input) {
|
|
743
|
-
if (!input.config) {
|
|
744
|
-
throw new Error("Plugin inbound.subscribe did not receive runtime config");
|
|
745
|
-
}
|
|
746
|
-
return subscribeToLessinboxEvents({
|
|
747
|
-
config: input.config,
|
|
748
|
-
accountId: input.accountId,
|
|
749
|
-
onEvent: input.onEvent
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
1563
|
const plugin = {
|
|
753
1564
|
id: "lessinbox",
|
|
754
1565
|
meta: {
|
|
@@ -760,7 +1571,12 @@ const plugin = {
|
|
|
760
1571
|
aliases: ["li", "lessinbox"]
|
|
761
1572
|
},
|
|
762
1573
|
capabilities: {
|
|
763
|
-
chatTypes: ["direct", "thread"]
|
|
1574
|
+
chatTypes: ["direct", "thread"],
|
|
1575
|
+
threads: true,
|
|
1576
|
+
media: true
|
|
1577
|
+
},
|
|
1578
|
+
reload: {
|
|
1579
|
+
configPrefixes: ["channels.lessinbox"]
|
|
764
1580
|
},
|
|
765
1581
|
config: {
|
|
766
1582
|
listAccountIds: (cfg) => {
|
|
@@ -770,14 +1586,36 @@ const plugin = {
|
|
|
770
1586
|
}
|
|
771
1587
|
return Object.keys(accounts);
|
|
772
1588
|
},
|
|
773
|
-
resolveAccount: (cfg, accountId) => resolveAccountConfig(cfg, accountId)
|
|
1589
|
+
resolveAccount: (cfg, accountId) => resolveAccountConfig(cfg, accountId),
|
|
1590
|
+
isConfigured: (account) => Boolean(account.apiUrl && account.apiKey && account.workspaceId && account.defaultChannelId),
|
|
1591
|
+
describeAccount: (account) => ({
|
|
1592
|
+
accountId: account.accountId ?? "default",
|
|
1593
|
+
enabled: account.enabled !== false,
|
|
1594
|
+
configured: Boolean(account.apiUrl && account.apiKey && account.workspaceId && account.defaultChannelId),
|
|
1595
|
+
workspaceId: account.workspaceId,
|
|
1596
|
+
apiUrl: account.apiUrl
|
|
1597
|
+
})
|
|
774
1598
|
},
|
|
775
1599
|
outbound: {
|
|
776
1600
|
deliveryMode: "direct",
|
|
777
|
-
sendText: sendTextToLessinbox
|
|
1601
|
+
sendText: sendTextToLessinbox,
|
|
1602
|
+
sendMedia: sendMediaToLessinbox
|
|
778
1603
|
},
|
|
779
|
-
|
|
780
|
-
|
|
1604
|
+
gateway: {
|
|
1605
|
+
startAccount: startLessinboxGatewayAccount,
|
|
1606
|
+
stopAccount: stopLessinboxGatewayAccount
|
|
1607
|
+
},
|
|
1608
|
+
status: {
|
|
1609
|
+
defaultRuntime: {
|
|
1610
|
+
accountId: "default",
|
|
1611
|
+
running: false,
|
|
1612
|
+
connected: false,
|
|
1613
|
+
lastConnectedAt: null,
|
|
1614
|
+
lastDisconnect: null,
|
|
1615
|
+
lastStartAt: null,
|
|
1616
|
+
lastStopAt: null,
|
|
1617
|
+
lastError: null
|
|
1618
|
+
}
|
|
781
1619
|
}
|
|
782
1620
|
};
|
|
783
1621
|
export function createLessinboxPlugin() {
|
|
@@ -787,10 +1625,15 @@ export default function register(api) {
|
|
|
787
1625
|
if (!api || typeof api.registerChannel !== "function") {
|
|
788
1626
|
throw new Error("Openclaw plugin runtime did not expose registerChannel");
|
|
789
1627
|
}
|
|
1628
|
+
setLessinboxPluginRuntime(api.runtime);
|
|
790
1629
|
api.registerChannel({ plugin });
|
|
791
|
-
if (typeof api.
|
|
792
|
-
api.
|
|
793
|
-
|
|
1630
|
+
if (typeof api.registerService === "function") {
|
|
1631
|
+
api.registerService({
|
|
1632
|
+
id: "lessinbox-channel-runtime",
|
|
1633
|
+
start: () => undefined,
|
|
1634
|
+
stop: async () => {
|
|
1635
|
+
await shutdownLessinboxPluginResources();
|
|
1636
|
+
}
|
|
794
1637
|
});
|
|
795
1638
|
}
|
|
796
1639
|
}
|