@evgenyy/lessinbox-channel 0.1.6 → 0.1.9

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
@@ -2,7 +2,9 @@ import Redis from "ioredis";
2
2
  import WebSocket from "ws";
3
3
  const DEFAULT_RECONNECT_MS = 1_500;
4
4
  const MIN_RECONNECT_MS = 250;
5
+ const INBOUND_MESSAGE_DEDUPE_TTL_MS = 5 * 60 * 1000;
5
6
  const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "canceled"]);
7
+ const ACTIVE_RUN_STATUSES = new Set(["queued", "running", "waiting_for_human"]);
6
8
  function normalizeBaseUrl(baseUrl) {
7
9
  return baseUrl.replace(/\/+$/, "");
8
10
  }
@@ -91,10 +93,20 @@ function parseRunRef(value) {
91
93
  const threadId = toOptionalString(record.threadId);
92
94
  const runId = toOptionalString(record.runId);
93
95
  const status = toOptionalString(record.status);
94
- if (!threadId || !runId) {
96
+ if (!threadId) {
95
97
  return undefined;
96
98
  }
97
- return { threadId, runId, status };
99
+ return {
100
+ threadId,
101
+ ...(runId ? { runId } : {}),
102
+ ...(status ? { status } : {})
103
+ };
104
+ }
105
+ function isTerminalRunStatus(status) {
106
+ return Boolean(status && TERMINAL_RUN_STATUSES.has(status));
107
+ }
108
+ function isActiveRunStatus(status) {
109
+ return Boolean(status && ACTIVE_RUN_STATUSES.has(status));
98
110
  }
99
111
  function extractRunId(payload) {
100
112
  const record = asRecord(payload);
@@ -148,19 +160,19 @@ function buildWorkspaceWsEndpoint(apiUrl, wsUrlOverride) {
148
160
  url.search = "";
149
161
  const normalizedPath = url.pathname.replace(/\/+$/, "");
150
162
  if (!normalizedPath || normalizedPath === "/") {
151
- url.pathname = "/v1/ws";
163
+ url.pathname = "/v2/ws";
152
164
  return url.toString();
153
165
  }
154
- if (normalizedPath.endsWith("/v2")) {
166
+ if (normalizedPath.endsWith("/v1")) {
155
167
  const prefix = normalizedPath.slice(0, -3);
156
- url.pathname = `${prefix || ""}/v1/ws`;
168
+ url.pathname = `${prefix || ""}/v2/ws`;
157
169
  return url.toString();
158
170
  }
159
- if (normalizedPath.endsWith("/v1")) {
171
+ if (normalizedPath.endsWith("/v2")) {
160
172
  url.pathname = `${normalizedPath}/ws`;
161
173
  return url.toString();
162
174
  }
163
- url.pathname = `${normalizedPath}/v1/ws`;
175
+ url.pathname = `${normalizedPath}/v2/ws`;
164
176
  return url.toString();
165
177
  }
166
178
  function buildWorkspaceWsUrl(apiUrl, token, wsUrlOverride) {
@@ -389,14 +401,40 @@ class WorkspaceStreamConsumer {
389
401
  }
390
402
  const payload = asRecord(event.payload);
391
403
  const toStatus = toOptionalString(payload?.to);
392
- if (!toStatus || !TERMINAL_RUN_STATUSES.has(toStatus)) {
404
+ const mappedConversationId = await this.options.mappingStore.getConversationKeyForRun(event.runId);
405
+ const conversationId = event.conversationId ?? mappedConversationId ?? (event.threadId ? `thread:${event.threadId}` : undefined);
406
+ if (!conversationId) {
407
+ if (isTerminalRunStatus(toStatus)) {
408
+ await this.options.mappingStore.deleteRun(event.runId);
409
+ }
393
410
  return;
394
411
  }
395
- const conversationId = event.conversationId ?? (await this.options.mappingStore.getConversationKeyForRun(event.runId));
396
- if (conversationId) {
397
- await this.options.mappingStore.deleteConversation(conversationId);
412
+ const existing = await this.options.mappingStore.getConversationRun(conversationId);
413
+ const threadId = event.threadId ?? existing?.threadId;
414
+ if (!threadId) {
415
+ if (isTerminalRunStatus(toStatus)) {
416
+ await this.options.mappingStore.deleteRun(event.runId);
417
+ }
418
+ return;
398
419
  }
399
- await this.options.mappingStore.deleteRun(event.runId);
420
+ const resolvedStatus = toStatus ?? existing?.status;
421
+ if (isTerminalRunStatus(toStatus)) {
422
+ await this.options.mappingStore.setConversationRun(conversationId, {
423
+ threadId,
424
+ ...(resolvedStatus ? { status: resolvedStatus } : {})
425
+ });
426
+ await this.options.mappingStore.deleteRun(event.runId);
427
+ return;
428
+ }
429
+ if (existing?.runId && existing.runId !== event.runId) {
430
+ await this.options.mappingStore.deleteRun(existing.runId);
431
+ }
432
+ await this.options.mappingStore.setConversationRun(conversationId, {
433
+ threadId,
434
+ runId: event.runId,
435
+ ...(resolvedStatus ? { status: resolvedStatus } : {})
436
+ });
437
+ await this.options.mappingStore.setConversationKeyForRun(event.runId, conversationId);
400
438
  }
401
439
  async emit(event) {
402
440
  for (const listener of this.listeners) {
@@ -453,6 +491,10 @@ function ensureWorkspaceConsumer(accountId, account, api, mappingStore) {
453
491
  return consumer;
454
492
  }
455
493
  export async function shutdownLessinboxPluginResources() {
494
+ for (const unsubscribe of gatewayUnsubscribers.values()) {
495
+ unsubscribe();
496
+ }
497
+ gatewayUnsubscribers.clear();
456
498
  for (const consumer of workspaceConsumers.values()) {
457
499
  consumer.stop();
458
500
  }
@@ -461,6 +503,8 @@ export async function shutdownLessinboxPluginResources() {
461
503
  await store.close();
462
504
  }
463
505
  mappingStores.clear();
506
+ handledInboundMessages.clear();
507
+ lessinboxPluginRuntime = null;
464
508
  }
465
509
  export class LessinboxApi {
466
510
  constructor(config) {
@@ -524,6 +568,12 @@ export class LessinboxApi {
524
568
  idempotencyKey: randomIdempotencyKey()
525
569
  });
526
570
  }
571
+ async getRun(runId) {
572
+ return this.request(`/v2/runs/${runId}`, { method: "GET" });
573
+ }
574
+ async getThread(threadId) {
575
+ return this.request(`/v2/threads/${threadId}`, { method: "GET" });
576
+ }
527
577
  async listThreads(input = {}) {
528
578
  const params = new URLSearchParams();
529
579
  if (input.bucket)
@@ -538,11 +588,22 @@ export class LessinboxApi {
538
588
  return this.request(`/v2/threads${query ? `?${query}` : ""}`, { method: "GET" });
539
589
  }
540
590
  async createWorkspaceWsToken() {
541
- return this.request("/v1/workspace/ws-token", {
591
+ return this.request("/v2/workspace/ws-token", {
542
592
  method: "POST",
543
593
  body: {}
544
594
  });
545
595
  }
596
+ async getThreadFeed(input) {
597
+ const params = new URLSearchParams();
598
+ if (typeof input.limit === "number") {
599
+ params.set("limit", String(input.limit));
600
+ }
601
+ if (input.cursor) {
602
+ params.set("cursor", input.cursor);
603
+ }
604
+ const query = params.toString();
605
+ return this.request(`/v2/threads/${input.threadId}/feed${query ? `?${query}` : ""}`, { method: "GET" });
606
+ }
546
607
  async request(path, options) {
547
608
  const headers = new Headers({
548
609
  Authorization: `Bearer ${this.apiKey}`,
@@ -598,6 +659,7 @@ export function resolveAccountConfig(config, accountId) {
598
659
  }
599
660
  const mappingStore = resolveMappingStoreKind(account.mappingStore);
600
661
  return {
662
+ accountId: resolvedId,
601
663
  enabled: typeof account.enabled === "boolean" ? account.enabled : true,
602
664
  apiUrl,
603
665
  apiKey,
@@ -609,6 +671,49 @@ export function resolveAccountConfig(config, accountId) {
609
671
  workspaceStream: resolveWorkspaceStreamConfig(account.workspaceStream)
610
672
  };
611
673
  }
674
+ const gatewayUnsubscribers = new Map();
675
+ const handledInboundMessages = new Map();
676
+ let lessinboxPluginRuntime = null;
677
+ function setLessinboxPluginRuntime(runtime) {
678
+ if (!runtime || typeof runtime !== "object") {
679
+ lessinboxPluginRuntime = null;
680
+ return;
681
+ }
682
+ lessinboxPluginRuntime = runtime;
683
+ }
684
+ function getLessinboxPluginRuntime() {
685
+ return lessinboxPluginRuntime;
686
+ }
687
+ function formatError(err) {
688
+ if (err instanceof Error && err.message) {
689
+ return err.message;
690
+ }
691
+ if (typeof err === "string") {
692
+ return err;
693
+ }
694
+ try {
695
+ return JSON.stringify(err);
696
+ }
697
+ catch {
698
+ return String(err);
699
+ }
700
+ }
701
+ function sleep(ms) {
702
+ return new Promise((resolve) => {
703
+ setTimeout(resolve, ms);
704
+ });
705
+ }
706
+ function toOptionalTimestamp(value) {
707
+ if (typeof value === "number" && Number.isFinite(value)) {
708
+ return value;
709
+ }
710
+ const asString = toOptionalString(value);
711
+ if (!asString) {
712
+ return undefined;
713
+ }
714
+ const parsed = Date.parse(asString);
715
+ return Number.isFinite(parsed) ? parsed : undefined;
716
+ }
612
717
  function deriveConversationKey(input) {
613
718
  if (input.conversationId && input.conversationId.trim().length > 0) {
614
719
  return input.conversationId;
@@ -618,11 +723,197 @@ function deriveConversationKey(input) {
618
723
  }
619
724
  return null;
620
725
  }
726
+ function listAccountIds(config) {
727
+ const accounts = readPath(config, ["channels", "lessinbox", "accounts"]);
728
+ if (!accounts || typeof accounts !== "object") {
729
+ return [];
730
+ }
731
+ return Object.keys(accounts);
732
+ }
733
+ function resolveSendTarget(input) {
734
+ const explicitTarget = toOptionalString(input.target) ??
735
+ toOptionalString(input.to) ??
736
+ toOptionalString(input.recipient) ??
737
+ toOptionalString(input.metadata?.target);
738
+ if (!explicitTarget) {
739
+ return {};
740
+ }
741
+ const separatorIndex = explicitTarget.indexOf(":");
742
+ if (separatorIndex > 0 && separatorIndex < explicitTarget.length - 1) {
743
+ const kind = explicitTarget.slice(0, separatorIndex).toLowerCase();
744
+ const value = explicitTarget.slice(separatorIndex + 1).trim();
745
+ if (!value) {
746
+ return {};
747
+ }
748
+ if (kind === "thread") {
749
+ return { threadId: value, conversationId: `thread:${value}` };
750
+ }
751
+ if (kind === "channel") {
752
+ return { channelId: value, conversationId: `channel:${value}` };
753
+ }
754
+ if (kind === "account") {
755
+ return { accountId: value };
756
+ }
757
+ if (kind === "run" || kind === "session") {
758
+ return { runId: value };
759
+ }
760
+ if (kind === "conversation") {
761
+ return { conversationId: value };
762
+ }
763
+ }
764
+ if (/^thr_[a-zA-Z0-9]+$/.test(explicitTarget)) {
765
+ return { threadId: explicitTarget, conversationId: `thread:${explicitTarget}` };
766
+ }
767
+ if (/^chn_[a-zA-Z0-9]+$/.test(explicitTarget)) {
768
+ return { channelId: explicitTarget, conversationId: `channel:${explicitTarget}` };
769
+ }
770
+ const accountIds = input.config ? listAccountIds(input.config) : [];
771
+ if (accountIds.includes(explicitTarget)) {
772
+ return { accountId: explicitTarget };
773
+ }
774
+ // Raw IDs default to channel target so CLI `--target <channelId>` works out of the box.
775
+ return { channelId: explicitTarget, conversationId: `channel:${explicitTarget}` };
776
+ }
777
+ function deriveChannelIdFromConversationKey(conversationKey) {
778
+ if (!conversationKey || !conversationKey.startsWith("channel:")) {
779
+ return undefined;
780
+ }
781
+ const channelId = conversationKey.slice("channel:".length).trim();
782
+ return channelId.length > 0 ? channelId : undefined;
783
+ }
784
+ function deriveTitle(input) {
785
+ if (input.title && input.title.trim().length > 0) {
786
+ return input.title.trim();
787
+ }
788
+ return input.text.slice(0, 80);
789
+ }
790
+ function toRunRefFromThreadSummary(summary) {
791
+ const runId = toOptionalString(summary.session_id) ?? toOptionalString(summary.run_id);
792
+ const status = toOptionalString(summary.session_status) ?? toOptionalString(summary.run_status);
793
+ return {
794
+ threadId: summary.id,
795
+ ...(runId ? { runId } : {}),
796
+ ...(status ? { status } : {})
797
+ };
798
+ }
799
+ function toRunRefFromThreadDetail(thread) {
800
+ const session = asRecord(thread.session);
801
+ const run = asRecord(thread.run);
802
+ const runId = toOptionalString(session?.id) ?? toOptionalString(run?.id);
803
+ const status = toOptionalString(session?.status) ?? toOptionalString(run?.status);
804
+ return {
805
+ threadId: thread.id,
806
+ ...(runId ? { runId } : {}),
807
+ ...(status ? { status } : {})
808
+ };
809
+ }
810
+ function normalizeConversationMetadata(metadata, conversationKey) {
811
+ if (!conversationKey) {
812
+ return metadata;
813
+ }
814
+ return {
815
+ ...(metadata ?? {}),
816
+ conversation_id: conversationKey
817
+ };
818
+ }
819
+ function purgeHandledInboundMessages(now) {
820
+ for (const [key, expiresAt] of handledInboundMessages.entries()) {
821
+ if (expiresAt <= now) {
822
+ handledInboundMessages.delete(key);
823
+ }
824
+ }
825
+ }
826
+ function hasHandledInboundMessage(key) {
827
+ const now = Date.now();
828
+ purgeHandledInboundMessages(now);
829
+ const expiresAt = handledInboundMessages.get(key);
830
+ return typeof expiresAt === "number" && expiresAt > now;
831
+ }
832
+ function markHandledInboundMessage(key) {
833
+ handledInboundMessages.set(key, Date.now() + INBOUND_MESSAGE_DEDUPE_TTL_MS);
834
+ }
835
+ function deriveHandledInboundKey(accountId, messageId) {
836
+ return `${accountId}:${messageId}`;
837
+ }
838
+ function resolveMessageActorType(message) {
839
+ const actor = asRecord(message?.actor);
840
+ return toOptionalString(actor?.type) ?? "unknown";
841
+ }
842
+ function resolveMessageActorUserId(message) {
843
+ const actor = asRecord(message?.actor);
844
+ return toOptionalString(actor?.user_id) ?? toOptionalString(actor?.userId);
845
+ }
846
+ function resolveEventPayloadMessage(event) {
847
+ const payload = asRecord(event.payload);
848
+ const message = asRecord(payload?.message);
849
+ const id = toOptionalString(message?.id);
850
+ if (!id) {
851
+ return undefined;
852
+ }
853
+ return {
854
+ id,
855
+ run_id: toOptionalString(message?.run_id) ?? undefined,
856
+ session_id: toOptionalString(message?.session_id) ?? undefined,
857
+ kind: toOptionalString(message?.kind),
858
+ text: toOptionalString(message?.text),
859
+ actor: asRecord(message?.actor),
860
+ created_at: toOptionalString(message?.created_at)
861
+ };
862
+ }
863
+ async function findFeedMessageById(api, threadId, messageId) {
864
+ for (let attempt = 0; attempt < 3; attempt += 1) {
865
+ try {
866
+ const feed = await api.getThreadFeed({ threadId, limit: 50 });
867
+ const found = feed.entries.find((entry) => toOptionalString(entry?.message?.id) === messageId)?.message;
868
+ if (found) {
869
+ return found;
870
+ }
871
+ }
872
+ catch {
873
+ // ignore and retry
874
+ }
875
+ if (attempt < 2) {
876
+ await sleep((attempt + 1) * 120);
877
+ }
878
+ }
879
+ return undefined;
880
+ }
881
+ function coalesceInboundMessage(messageId, payloadMessage, feedMessage) {
882
+ const text = toOptionalString(feedMessage?.text) ??
883
+ toOptionalString(payloadMessage?.text);
884
+ if (!text) {
885
+ return null;
886
+ }
887
+ const actorType = resolveMessageActorType(feedMessage) !== "unknown"
888
+ ? resolveMessageActorType(feedMessage)
889
+ : resolveMessageActorType(payloadMessage);
890
+ const actorUserId = resolveMessageActorUserId(feedMessage) ??
891
+ resolveMessageActorUserId(payloadMessage);
892
+ const runId = toOptionalString(feedMessage?.run_id) ??
893
+ toOptionalString(feedMessage?.session_id) ??
894
+ toOptionalString(payloadMessage?.run_id) ??
895
+ toOptionalString(payloadMessage?.session_id);
896
+ const createdAtMs = toOptionalTimestamp(feedMessage?.created_at) ??
897
+ toOptionalTimestamp(payloadMessage?.created_at);
898
+ return {
899
+ id: messageId,
900
+ text,
901
+ actorType,
902
+ ...(actorUserId ? { actorUserId } : {}),
903
+ ...(runId ? { runId } : {}),
904
+ ...(typeof createdAtMs === "number" ? { createdAtMs } : {})
905
+ };
906
+ }
621
907
  async function sendTextToLessinbox(input) {
622
908
  if (!input.config) {
623
909
  throw new Error("Plugin sendText did not receive runtime config");
624
910
  }
625
- const accountRuntimeId = deriveAccountRuntimeId(input.accountId);
911
+ const resolvedTarget = resolveSendTarget(input);
912
+ const resolvedThreadId = input.threadId ?? resolvedTarget.threadId;
913
+ const resolvedRunId = input.runId ?? resolvedTarget.runId;
914
+ const resolvedChannelId = input.channelId ?? resolvedTarget.channelId;
915
+ const resolvedConversationId = input.conversationId ?? resolvedTarget.conversationId;
916
+ const accountRuntimeId = deriveAccountRuntimeId(input.accountId ?? resolvedTarget.accountId);
626
917
  const account = resolveAccountConfig(input.config, accountRuntimeId);
627
918
  if (account.enabled === false) {
628
919
  throw new Error("Lessinbox account is disabled");
@@ -630,40 +921,376 @@ async function sendTextToLessinbox(input) {
630
921
  const api = new LessinboxApi(account);
631
922
  const mappingStore = getConversationMappingStore(accountRuntimeId, account);
632
923
  ensureWorkspaceConsumer(accountRuntimeId, account, api, mappingStore);
633
- const conversationKey = deriveConversationKey(input);
924
+ const conversationKey = deriveConversationKey({
925
+ ...input,
926
+ threadId: resolvedThreadId,
927
+ conversationId: resolvedConversationId
928
+ });
634
929
  const mappedRun = conversationKey ? await mappingStore.getConversationRun(conversationKey) : undefined;
635
- let threadId = input.threadId ?? mappedRun?.threadId;
636
- let runId = input.runId ?? mappedRun?.runId;
637
- if (!threadId || !runId) {
638
- const title = (input.title && input.title.trim().length > 0 ? input.title.trim() : null) ??
639
- input.text.slice(0, 80);
930
+ let threadId = resolvedThreadId ?? mappedRun?.threadId;
931
+ let runId = resolvedRunId ?? mappedRun?.runId;
932
+ let runStatus = mappedRun?.status;
933
+ let channelId = resolvedChannelId ?? deriveChannelIdFromConversationKey(conversationKey);
934
+ // Recover latest known thread for channel-targeted conversations when in-memory mapping is empty.
935
+ if (!threadId && !runId && channelId) {
936
+ const listing = await api.listThreads({
937
+ bucket: "all",
938
+ channelId,
939
+ limit: 1
940
+ });
941
+ const latest = listing.threads[0];
942
+ if (latest) {
943
+ const recovered = toRunRefFromThreadSummary(latest);
944
+ threadId = recovered.threadId;
945
+ runId = recovered.runId;
946
+ runStatus = recovered.status;
947
+ }
948
+ }
949
+ // Thread details give us canonical channel and latest session snapshot.
950
+ if (threadId && (!channelId || !runId || !runStatus)) {
951
+ const thread = await api.getThread(threadId);
952
+ channelId = channelId ?? thread.channel_id;
953
+ const detailRef = toRunRefFromThreadDetail(thread);
954
+ if (!runId && detailRef.runId) {
955
+ runId = detailRef.runId;
956
+ }
957
+ if (!runStatus && detailRef.status) {
958
+ runStatus = detailRef.status;
959
+ }
960
+ }
961
+ // For mapped runs, verify status so terminal sessions rotate automatically.
962
+ if (runId && !resolvedRunId) {
963
+ try {
964
+ const runSnapshot = await api.getRun(runId);
965
+ const run = runSnapshot.run;
966
+ runStatus = toOptionalString(run.status) ?? runStatus;
967
+ if (!threadId) {
968
+ threadId = toOptionalString(run.thread_id) ?? threadId;
969
+ }
970
+ channelId = channelId ?? toOptionalString(run.channel_id);
971
+ }
972
+ catch {
973
+ runId = undefined;
974
+ runStatus = undefined;
975
+ }
976
+ }
977
+ if (runId && !resolvedRunId && !isActiveRunStatus(runStatus)) {
978
+ await mappingStore.deleteRun(runId);
979
+ runId = undefined;
980
+ }
981
+ if (!runId) {
640
982
  const started = await api.startRun({
641
- title,
642
- channelId: input.channelId ?? api.getDefaultChannelId(),
643
- metadata: input.metadata
983
+ title: deriveTitle(input),
984
+ channelId: channelId ?? api.getDefaultChannelId(),
985
+ threadId,
986
+ metadata: normalizeConversationMetadata(input.metadata, conversationKey)
644
987
  });
645
988
  threadId = started.thread_id;
646
989
  runId = started.run_id;
990
+ runStatus = started.session_status ?? started.status;
991
+ channelId = channelId ?? api.getDefaultChannelId();
992
+ }
993
+ if (!threadId) {
994
+ throw new Error("Unable to resolve Lessinbox thread for outbound message");
647
995
  }
648
- await api.postMessage({
996
+ const message = await api.postMessage({
649
997
  threadId,
650
998
  runId,
651
999
  text: input.text,
652
1000
  kind: "text"
653
1001
  });
1002
+ const resolvedMessageRunId = message.run_id ?? runId;
654
1003
  if (conversationKey) {
655
- if (mappedRun?.runId && mappedRun.runId !== runId) {
1004
+ if (mappedRun?.runId && mappedRun.runId !== resolvedMessageRunId) {
656
1005
  await mappingStore.deleteRun(mappedRun.runId);
657
1006
  }
658
- await mappingStore.setConversationRun(conversationKey, { threadId, runId });
659
- await mappingStore.setConversationKeyForRun(runId, conversationKey);
1007
+ await mappingStore.setConversationRun(conversationKey, {
1008
+ threadId,
1009
+ ...(resolvedMessageRunId ? { runId: resolvedMessageRunId } : {}),
1010
+ ...(runStatus ? { status: runStatus } : {})
1011
+ });
1012
+ if (resolvedMessageRunId) {
1013
+ await mappingStore.setConversationKeyForRun(resolvedMessageRunId, conversationKey);
1014
+ }
660
1015
  }
661
1016
  return {
662
1017
  ok: true,
663
1018
  threadId,
664
- runId
1019
+ runId: resolvedMessageRunId ?? runId
665
1020
  };
666
1021
  }
1022
+ async function sendMediaToLessinbox(input) {
1023
+ const mediaUrl = toOptionalString(input.mediaUrl);
1024
+ const caption = input.text?.trim() ?? "";
1025
+ if (!mediaUrl) {
1026
+ return sendTextToLessinbox({
1027
+ ...input,
1028
+ text: caption
1029
+ });
1030
+ }
1031
+ const messageText = [caption, `Attachment: ${mediaUrl}`].filter(Boolean).join("\n\n");
1032
+ return sendTextToLessinbox({
1033
+ ...input,
1034
+ text: messageText || `Attachment: ${mediaUrl}`
1035
+ });
1036
+ }
1037
+ async function sendReplyPayloadToLessinbox(input) {
1038
+ const mediaUrls = input.payload.mediaUrls?.length
1039
+ ? input.payload.mediaUrls
1040
+ : input.payload.mediaUrl
1041
+ ? [input.payload.mediaUrl]
1042
+ : [];
1043
+ if (mediaUrls.length === 0) {
1044
+ await sendTextToLessinbox({
1045
+ text: input.payload.text ?? "",
1046
+ accountId: input.accountId,
1047
+ config: input.config,
1048
+ threadId: input.threadId,
1049
+ runId: input.runId,
1050
+ conversationId: `thread:${input.threadId}`
1051
+ });
1052
+ return;
1053
+ }
1054
+ let first = true;
1055
+ for (const mediaUrl of mediaUrls) {
1056
+ await sendMediaToLessinbox({
1057
+ text: first ? input.payload.text ?? "" : "",
1058
+ mediaUrl,
1059
+ accountId: input.accountId,
1060
+ config: input.config,
1061
+ threadId: input.threadId,
1062
+ runId: input.runId,
1063
+ conversationId: `thread:${input.threadId}`
1064
+ });
1065
+ first = false;
1066
+ }
1067
+ }
1068
+ async function dispatchInboundMessageToRuntime(input) {
1069
+ const routing = input.runtime.channel?.routing?.resolveAgentRoute;
1070
+ const replyRuntime = input.runtime.channel?.reply;
1071
+ if (!routing || !replyRuntime?.finalizeInboundContext || !replyRuntime.dispatchReplyWithBufferedBlockDispatcher) {
1072
+ return;
1073
+ }
1074
+ const route = routing({
1075
+ cfg: input.cfg,
1076
+ channel: "lessinbox",
1077
+ accountId: input.accountId,
1078
+ peer: {
1079
+ kind: "direct",
1080
+ id: input.threadId
1081
+ }
1082
+ });
1083
+ const routeSessionKey = toOptionalString(route.sessionKey) ??
1084
+ toOptionalString(route.mainSessionKey) ??
1085
+ `agent:main:lessinbox:thread:${input.threadId}`;
1086
+ const routeAccountId = toOptionalString(route.accountId) ?? input.accountId;
1087
+ const routeAgentId = toOptionalString(route.agentId);
1088
+ const ctxPayload = replyRuntime.finalizeInboundContext({
1089
+ Body: input.message.text,
1090
+ BodyForAgent: input.message.text,
1091
+ RawBody: input.message.text,
1092
+ CommandBody: input.message.text,
1093
+ From: `lessinbox:thread:${input.threadId}`,
1094
+ To: `thread:${input.threadId}`,
1095
+ SessionKey: routeSessionKey,
1096
+ AccountId: routeAccountId,
1097
+ ChatType: "direct",
1098
+ ConversationLabel: `Lessinbox thread ${input.threadId}`,
1099
+ SenderId: input.message.actorUserId ?? `thread:${input.threadId}`,
1100
+ Provider: "lessinbox",
1101
+ Surface: "lessinbox",
1102
+ MessageSid: input.message.id,
1103
+ Timestamp: input.message.createdAtMs ?? Date.now(),
1104
+ OriginatingChannel: "lessinbox",
1105
+ OriginatingTo: `thread:${input.threadId}`,
1106
+ CommandAuthorized: true
1107
+ });
1108
+ const sessionStore = asRecord(input.cfg)?.session;
1109
+ const resolveStorePath = input.runtime.channel?.session?.resolveStorePath;
1110
+ const recordInboundSession = input.runtime.channel?.session?.recordInboundSession;
1111
+ if (resolveStorePath && recordInboundSession) {
1112
+ const storePath = resolveStorePath(asRecord(sessionStore)?.store, { agentId: routeAgentId });
1113
+ await recordInboundSession({
1114
+ storePath,
1115
+ sessionKey: routeSessionKey,
1116
+ ctx: ctxPayload,
1117
+ onRecordError: (err) => {
1118
+ input.log?.error?.(`lessinbox failed recording inbound session metadata: ${formatError(err)}`);
1119
+ }
1120
+ });
1121
+ }
1122
+ input.runtime.system?.enqueueSystemEvent?.(`Lessinbox message in thread ${input.threadId}: ${input.message.text.slice(0, 160)}`, {
1123
+ sessionKey: routeSessionKey,
1124
+ contextKey: `lessinbox:message:${input.threadId}:${input.message.id}`
1125
+ });
1126
+ await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({
1127
+ ctx: ctxPayload,
1128
+ cfg: input.cfg,
1129
+ dispatcherOptions: {
1130
+ deliver: async (payload) => {
1131
+ await sendReplyPayloadToLessinbox({
1132
+ payload,
1133
+ accountId: input.accountId,
1134
+ config: input.cfg,
1135
+ threadId: input.threadId,
1136
+ runId: input.runId
1137
+ });
1138
+ input.runtime.channel?.activity?.record?.({
1139
+ channel: "lessinbox",
1140
+ accountId: input.accountId,
1141
+ direction: "outbound"
1142
+ });
1143
+ },
1144
+ onError: (err, info) => {
1145
+ input.log?.error?.(`lessinbox ${info.kind} reply failed: ${formatError(err)}`);
1146
+ }
1147
+ }
1148
+ });
1149
+ }
1150
+ async function handleGatewayInboundEvent(input) {
1151
+ if (input.event.kind !== "message.created") {
1152
+ return;
1153
+ }
1154
+ const threadId = input.event.threadId;
1155
+ if (!threadId) {
1156
+ return;
1157
+ }
1158
+ const payloadMessage = resolveEventPayloadMessage(input.event);
1159
+ const messageId = toOptionalString(payloadMessage?.id);
1160
+ if (!messageId) {
1161
+ return;
1162
+ }
1163
+ const handledKey = deriveHandledInboundKey(input.accountId, messageId);
1164
+ if (hasHandledInboundMessage(handledKey)) {
1165
+ return;
1166
+ }
1167
+ const feedMessage = await findFeedMessageById(input.api, threadId, messageId);
1168
+ const inboundMessage = coalesceInboundMessage(messageId, payloadMessage, feedMessage);
1169
+ if (!inboundMessage) {
1170
+ return;
1171
+ }
1172
+ if (inboundMessage.actorType !== "user") {
1173
+ return;
1174
+ }
1175
+ markHandledInboundMessage(handledKey);
1176
+ const conversationId = input.event.conversationId ?? `thread:${threadId}`;
1177
+ const runId = inboundMessage.runId ?? input.event.runId;
1178
+ if (runId) {
1179
+ await input.mappingStore.setConversationRun(conversationId, {
1180
+ threadId,
1181
+ runId,
1182
+ status: "running"
1183
+ });
1184
+ await input.mappingStore.setConversationKeyForRun(runId, conversationId);
1185
+ }
1186
+ const at = inboundMessage.createdAtMs ?? Date.now();
1187
+ input.runtime.channel?.activity?.record?.({
1188
+ channel: "lessinbox",
1189
+ accountId: input.accountId,
1190
+ direction: "inbound",
1191
+ at
1192
+ });
1193
+ input.setStatus({
1194
+ accountId: input.accountId,
1195
+ connected: true,
1196
+ lastInboundAt: at,
1197
+ lastError: null
1198
+ });
1199
+ await dispatchInboundMessageToRuntime({
1200
+ runtime: input.runtime,
1201
+ cfg: input.cfg,
1202
+ accountId: input.accountId,
1203
+ threadId,
1204
+ runId,
1205
+ message: inboundMessage,
1206
+ log: input.log
1207
+ });
1208
+ }
1209
+ function cleanupConsumerIfUnused(consumerKey) {
1210
+ const consumer = workspaceConsumers.get(consumerKey);
1211
+ if (!consumer) {
1212
+ return;
1213
+ }
1214
+ if (consumer.listenerCount() > 0) {
1215
+ return;
1216
+ }
1217
+ consumer.stop();
1218
+ workspaceConsumers.delete(consumerKey);
1219
+ }
1220
+ async function startLessinboxGatewayAccount(ctx) {
1221
+ const runtime = getLessinboxPluginRuntime();
1222
+ const accountId = deriveAccountRuntimeId(ctx.accountId);
1223
+ if (!runtime) {
1224
+ ctx.setStatus({
1225
+ accountId,
1226
+ running: false,
1227
+ connected: false,
1228
+ lastError: "Lessinbox plugin runtime not initialized"
1229
+ });
1230
+ return;
1231
+ }
1232
+ if (ctx.account.enabled === false) {
1233
+ ctx.setStatus({
1234
+ accountId,
1235
+ running: false,
1236
+ connected: false,
1237
+ lastError: "Lessinbox account is disabled"
1238
+ });
1239
+ return;
1240
+ }
1241
+ const api = new LessinboxApi(ctx.account);
1242
+ const mappingStore = getConversationMappingStore(accountId, ctx.account);
1243
+ const consumer = ensureWorkspaceConsumer(accountId, ctx.account, api, mappingStore);
1244
+ const consumerKey = deriveWorkspaceConsumerRegistryKey(accountId, ctx.account);
1245
+ gatewayUnsubscribers.get(consumerKey)?.();
1246
+ gatewayUnsubscribers.delete(consumerKey);
1247
+ if (!consumer) {
1248
+ ctx.setStatus({
1249
+ accountId,
1250
+ running: false,
1251
+ connected: false,
1252
+ lastError: "Lessinbox workspace stream is disabled"
1253
+ });
1254
+ return;
1255
+ }
1256
+ const unsubscribe = consumer.addListener(async (event) => {
1257
+ await handleGatewayInboundEvent({
1258
+ runtime,
1259
+ cfg: ctx.cfg,
1260
+ api,
1261
+ accountId,
1262
+ mappingStore,
1263
+ event,
1264
+ setStatus: ctx.setStatus,
1265
+ log: ctx.log
1266
+ });
1267
+ });
1268
+ gatewayUnsubscribers.set(consumerKey, unsubscribe);
1269
+ consumer.start();
1270
+ ctx.setStatus({
1271
+ accountId,
1272
+ running: true,
1273
+ connected: true,
1274
+ lastStartAt: Date.now(),
1275
+ lastError: null
1276
+ });
1277
+ }
1278
+ async function stopLessinboxGatewayAccount(ctx) {
1279
+ const accountId = deriveAccountRuntimeId(ctx.accountId);
1280
+ const consumerKey = deriveWorkspaceConsumerRegistryKey(accountId, ctx.account);
1281
+ const unsubscribe = gatewayUnsubscribers.get(consumerKey);
1282
+ if (unsubscribe) {
1283
+ unsubscribe();
1284
+ gatewayUnsubscribers.delete(consumerKey);
1285
+ }
1286
+ cleanupConsumerIfUnused(consumerKey);
1287
+ ctx.setStatus({
1288
+ accountId,
1289
+ running: false,
1290
+ connected: false,
1291
+ lastStopAt: Date.now()
1292
+ });
1293
+ }
667
1294
  export function subscribeToLessinboxEvents(input) {
668
1295
  const accountRuntimeId = deriveAccountRuntimeId(input.accountId);
669
1296
  const account = resolveAccountConfig(input.config, accountRuntimeId);
@@ -678,16 +1305,6 @@ export function subscribeToLessinboxEvents(input) {
678
1305
  }
679
1306
  return consumer.addListener(input.onEvent);
680
1307
  }
681
- function subscribeFromPlugin(input) {
682
- if (!input.config) {
683
- throw new Error("Plugin inbound.subscribe did not receive runtime config");
684
- }
685
- return subscribeToLessinboxEvents({
686
- config: input.config,
687
- accountId: input.accountId,
688
- onEvent: input.onEvent
689
- });
690
- }
691
1308
  const plugin = {
692
1309
  id: "lessinbox",
693
1310
  meta: {
@@ -699,7 +1316,12 @@ const plugin = {
699
1316
  aliases: ["li", "lessinbox"]
700
1317
  },
701
1318
  capabilities: {
702
- chatTypes: ["direct", "thread"]
1319
+ chatTypes: ["direct", "thread"],
1320
+ threads: true,
1321
+ media: true
1322
+ },
1323
+ reload: {
1324
+ configPrefixes: ["channels.lessinbox"]
703
1325
  },
704
1326
  config: {
705
1327
  listAccountIds: (cfg) => {
@@ -709,14 +1331,36 @@ const plugin = {
709
1331
  }
710
1332
  return Object.keys(accounts);
711
1333
  },
712
- resolveAccount: (cfg, accountId) => resolveAccountConfig(cfg, accountId)
1334
+ resolveAccount: (cfg, accountId) => resolveAccountConfig(cfg, accountId),
1335
+ isConfigured: (account) => Boolean(account.apiUrl && account.apiKey && account.workspaceId && account.defaultChannelId),
1336
+ describeAccount: (account) => ({
1337
+ accountId: account.accountId ?? "default",
1338
+ enabled: account.enabled !== false,
1339
+ configured: Boolean(account.apiUrl && account.apiKey && account.workspaceId && account.defaultChannelId),
1340
+ workspaceId: account.workspaceId,
1341
+ apiUrl: account.apiUrl
1342
+ })
713
1343
  },
714
1344
  outbound: {
715
1345
  deliveryMode: "direct",
716
- sendText: sendTextToLessinbox
1346
+ sendText: sendTextToLessinbox,
1347
+ sendMedia: sendMediaToLessinbox
1348
+ },
1349
+ gateway: {
1350
+ startAccount: startLessinboxGatewayAccount,
1351
+ stopAccount: stopLessinboxGatewayAccount
717
1352
  },
718
- inbound: {
719
- subscribe: subscribeFromPlugin
1353
+ status: {
1354
+ defaultRuntime: {
1355
+ accountId: "default",
1356
+ running: false,
1357
+ connected: false,
1358
+ lastConnectedAt: null,
1359
+ lastDisconnect: null,
1360
+ lastStartAt: null,
1361
+ lastStopAt: null,
1362
+ lastError: null
1363
+ }
720
1364
  }
721
1365
  };
722
1366
  export function createLessinboxPlugin() {
@@ -726,10 +1370,15 @@ export default function register(api) {
726
1370
  if (!api || typeof api.registerChannel !== "function") {
727
1371
  throw new Error("Openclaw plugin runtime did not expose registerChannel");
728
1372
  }
1373
+ setLessinboxPluginRuntime(api.runtime);
729
1374
  api.registerChannel({ plugin });
730
- if (typeof api.onShutdown === "function") {
731
- api.onShutdown(async () => {
732
- await shutdownLessinboxPluginResources();
1375
+ if (typeof api.registerService === "function") {
1376
+ api.registerService({
1377
+ id: "lessinbox-channel-runtime",
1378
+ start: () => undefined,
1379
+ stop: async () => {
1380
+ await shutdownLessinboxPluginResources();
1381
+ }
733
1382
  });
734
1383
  }
735
1384
  }