@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/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 || !runId) {
146
+ if (!threadId) {
95
147
  return undefined;
96
148
  }
97
- return { threadId, runId, status };
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 = "/v1/ws";
213
+ url.pathname = "/v2/ws";
152
214
  return url.toString();
153
215
  }
154
- if (normalizedPath.endsWith("/v2")) {
216
+ if (normalizedPath.endsWith("/v1")) {
155
217
  const prefix = normalizedPath.slice(0, -3);
156
- url.pathname = `${prefix || ""}/v1/ws`;
218
+ url.pathname = `${prefix || ""}/v2/ws`;
157
219
  return url.toString();
158
220
  }
159
- if (normalizedPath.endsWith("/v1")) {
221
+ if (normalizedPath.endsWith("/v2")) {
160
222
  url.pathname = `${normalizedPath}/ws`;
161
223
  return url.toString();
162
224
  }
163
- url.pathname = `${normalizedPath}/v1/ws`;
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
- if (!toStatus || !TERMINAL_RUN_STATUSES.has(toStatus)) {
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 conversationId = event.conversationId ?? (await this.options.mappingStore.getConversationKeyForRun(event.runId));
396
- if (conversationId) {
397
- await this.options.mappingStore.deleteConversation(conversationId);
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
- await this.options.mappingStore.deleteRun(event.runId);
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("/v1/workspace/ws-token", {
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
- if (!threadId || !runId) {
698
- const title = (input.title && input.title.trim().length > 0 ? input.title.trim() : null) ??
699
- input.text.slice(0, 80);
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: resolvedChannelId ?? api.getDefaultChannelId(),
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 !== runId) {
1259
+ if (mappedRun?.runId && mappedRun.runId !== resolvedMessageRunId) {
717
1260
  await mappingStore.deleteRun(mappedRun.runId);
718
1261
  }
719
- await mappingStore.setConversationRun(conversationKey, { threadId, runId });
720
- await mappingStore.setConversationKeyForRun(runId, conversationKey);
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
- inbound: {
780
- subscribe: subscribeFromPlugin
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.onShutdown === "function") {
792
- api.onShutdown(async () => {
793
- await shutdownLessinboxPluginResources();
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
  }