@pnds/pond 1.7.0 → 1.9.0
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/package.json +3 -3
- package/src/gateway.ts +107 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "OpenClaw channel plugin for Pond IM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"openclaw.plugin.json"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@pnds/cli": "1.
|
|
19
|
-
"@pnds/sdk": "1.
|
|
18
|
+
"@pnds/cli": "1.9.0",
|
|
19
|
+
"@pnds/sdk": "1.9.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
package/src/gateway.ts
CHANGED
|
@@ -34,7 +34,7 @@ function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
|
34
34
|
return `${wsBase}/ws`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
type ChatInfo = { type: Chat["type"]; name: string | null };
|
|
37
|
+
type ChatInfo = { type: Chat["type"]; name: string | null; agentRoutingMode: Chat["agent_routing_mode"] };
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Fetch all chats with pagination and populate the info map (type + name).
|
|
@@ -49,7 +49,7 @@ async function fetchAllChats(
|
|
|
49
49
|
do {
|
|
50
50
|
const res = await client.getChats(orgId, { limit: 100, cursor });
|
|
51
51
|
for (const c of res.data) {
|
|
52
|
-
chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null });
|
|
52
|
+
chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null, agentRoutingMode: c.agent_routing_mode });
|
|
53
53
|
}
|
|
54
54
|
total += res.data.length;
|
|
55
55
|
cursor = res.has_more ? res.next_cursor : undefined;
|
|
@@ -402,7 +402,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
404
|
// Resolve remaining items as not-dispatched (fork stopped after error).
|
|
405
|
-
// These events are NOT acked —
|
|
405
|
+
// These events are NOT acked — dispatch retains them for catch-up replay.
|
|
406
406
|
for (const remaining of fork.queue.splice(0)) {
|
|
407
407
|
remaining.resolve(false);
|
|
408
408
|
}
|
|
@@ -535,10 +535,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
535
535
|
|
|
536
536
|
dispatchState.mainCurrentTargetId = event.targetId;
|
|
537
537
|
await runDispatch(event, orchestratorKey, bodyForAgent);
|
|
538
|
-
// Ack
|
|
538
|
+
// Ack dispatch after successful drain — these events were buffered
|
|
539
539
|
// without ack, so this is the first time they're confirmed processed.
|
|
540
540
|
const sourceType = event.type === "task" ? "task_activity" : "message";
|
|
541
|
-
client.
|
|
541
|
+
client.ackDispatch(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
|
|
542
542
|
}
|
|
543
543
|
} finally {
|
|
544
544
|
draining = false;
|
|
@@ -550,8 +550,8 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
550
550
|
/**
|
|
551
551
|
* Route and dispatch an inbound event.
|
|
552
552
|
* Returns true if the event was dispatched (main or fork), false if only buffered.
|
|
553
|
-
* Callers should only ack
|
|
554
|
-
* lost on crash and need to be replayed from
|
|
553
|
+
* Callers should only ack dispatch when this returns true — buffered events may be
|
|
554
|
+
* lost on crash and need to be replayed from dispatch on the next catch-up.
|
|
555
555
|
*/
|
|
556
556
|
async function handleInboundEvent(event: PondEvent): Promise<boolean> {
|
|
557
557
|
const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
|
|
@@ -683,13 +683,13 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
683
683
|
});
|
|
684
684
|
}, intervalSec * 1000);
|
|
685
685
|
|
|
686
|
-
//
|
|
686
|
+
// Dispatch catch-up: fetch pending dispatch events (FIFO).
|
|
687
687
|
// On cold start, skip items older than COLD_START_WINDOW_MS to avoid
|
|
688
688
|
// replaying historical backlog from a previous process.
|
|
689
689
|
const isFirstHello = !hadSuccessfulHello;
|
|
690
690
|
hadSuccessfulHello = true;
|
|
691
|
-
|
|
692
|
-
log?.warn(`pond[${ctx.accountId}]:
|
|
691
|
+
catchUpFromDispatch(isFirstHello).catch((err) => {
|
|
692
|
+
log?.warn(`pond[${ctx.accountId}]: dispatch catch-up failed: ${String(err)}`);
|
|
693
693
|
});
|
|
694
694
|
} catch (err) {
|
|
695
695
|
log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
@@ -700,13 +700,19 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
700
700
|
const changes = data.changes as Record<string, unknown> | undefined;
|
|
701
701
|
if (!changes) return;
|
|
702
702
|
const existing = chatInfoMap.get(data.chat_id);
|
|
703
|
-
if (
|
|
703
|
+
if (existing) {
|
|
704
|
+
chatInfoMap.set(data.chat_id, {
|
|
705
|
+
...existing,
|
|
706
|
+
...(typeof changes.type === "string" ? { type: changes.type as Chat["type"] } : {}),
|
|
707
|
+
...(typeof changes.name === "string" ? { name: changes.name } : {}),
|
|
708
|
+
...(typeof changes.agent_routing_mode === "string" ? { agentRoutingMode: changes.agent_routing_mode as Chat["agent_routing_mode"] } : {}),
|
|
709
|
+
});
|
|
710
|
+
} else if (typeof changes.type === "string") {
|
|
704
711
|
chatInfoMap.set(data.chat_id, {
|
|
705
712
|
type: changes.type as Chat["type"],
|
|
706
|
-
name: (typeof changes.name === "string" ? changes.name :
|
|
713
|
+
name: (typeof changes.name === "string" ? changes.name : null),
|
|
714
|
+
agentRoutingMode: (typeof changes.agent_routing_mode === "string" ? changes.agent_routing_mode : "passive") as Chat["agent_routing_mode"],
|
|
707
715
|
});
|
|
708
|
-
} else if (typeof changes.name === "string" && existing) {
|
|
709
|
-
chatInfoMap.set(data.chat_id, { ...existing, name: changes.name });
|
|
710
716
|
}
|
|
711
717
|
});
|
|
712
718
|
|
|
@@ -724,7 +730,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
724
730
|
if (!chatInfo) {
|
|
725
731
|
try {
|
|
726
732
|
const chat = await client.getChat(config.org_id, chatId);
|
|
727
|
-
chatInfo = { type: chat.type, name: chat.name ?? null };
|
|
733
|
+
chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
|
|
728
734
|
chatInfoMap.set(chatId, chatInfo);
|
|
729
735
|
} catch (err) {
|
|
730
736
|
log?.warn(`pond[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
|
|
@@ -741,17 +747,27 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
741
747
|
body = content.text?.trim() ?? "";
|
|
742
748
|
if (!body) return;
|
|
743
749
|
|
|
744
|
-
// In group chats,
|
|
745
|
-
|
|
750
|
+
// In group chats, respond based on agent_routing_mode:
|
|
751
|
+
// - passive (default): only when @mentioned or @all
|
|
752
|
+
// - active: all messages dispatched via live handler
|
|
753
|
+
// - smart: skip live handler — server decides routing via inbox items
|
|
754
|
+
if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
|
|
746
755
|
const mentions = content.mentions ?? [];
|
|
747
756
|
const isMentioned = mentions.some(
|
|
748
757
|
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
|
|
749
758
|
);
|
|
750
|
-
if (!isMentioned)
|
|
759
|
+
if (!isMentioned) {
|
|
760
|
+
// Release claim so catch-up can process this via dispatch events
|
|
761
|
+
dispatchedMessages.delete(data.id);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
751
764
|
}
|
|
752
765
|
} else {
|
|
753
766
|
const content = data.content as MediaContent;
|
|
754
|
-
if (chatInfo.type === "group")
|
|
767
|
+
if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
|
|
768
|
+
dispatchedMessages.delete(data.id);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
755
771
|
body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
|
|
756
772
|
if (content.attachment_id) {
|
|
757
773
|
try {
|
|
@@ -786,7 +802,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
786
802
|
try {
|
|
787
803
|
const dispatched = await handleInboundEvent(event);
|
|
788
804
|
if (dispatched) {
|
|
789
|
-
client.
|
|
805
|
+
client.ackDispatch(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
|
|
790
806
|
} else {
|
|
791
807
|
// Not dispatched (buffered or fork failed) — release claim so catch-up can retry
|
|
792
808
|
dispatchedMessages.delete(data.id);
|
|
@@ -845,7 +861,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
845
861
|
// Server event buffer overflowed — events were lost, trigger full catch-up
|
|
846
862
|
ws.on("recovery.overflow", () => {
|
|
847
863
|
log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
|
|
848
|
-
|
|
864
|
+
catchUpFromDispatch().catch((err) =>
|
|
849
865
|
log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
|
|
850
866
|
});
|
|
851
867
|
|
|
@@ -855,33 +871,43 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
855
871
|
try {
|
|
856
872
|
const dispatched = await handleTaskAssignment(data);
|
|
857
873
|
if (dispatched) {
|
|
858
|
-
client.
|
|
874
|
+
client.ackDispatch(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
|
|
859
875
|
}
|
|
860
876
|
} catch (err) {
|
|
861
877
|
log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
|
|
862
878
|
}
|
|
863
879
|
});
|
|
864
880
|
|
|
865
|
-
// ──
|
|
866
|
-
// Replays missed
|
|
867
|
-
//
|
|
868
|
-
|
|
881
|
+
// ── Dispatch catch-up ──
|
|
882
|
+
// Replays missed events via the dispatch API (agent-only endpoint, FIFO order).
|
|
883
|
+
// Each dispatch event is a source-pointer; we re-fetch the entity to build a PondEvent.
|
|
884
|
+
//
|
|
885
|
+
// Cold-start window: On a fresh process start (not reconnect), events older than
|
|
886
|
+
// COLD_START_WINDOW_MS are acked without processing. This is an intentional operator
|
|
887
|
+
// decision: a newly started agent should not replay unbounded historical backlog from
|
|
888
|
+
// a previous process lifetime. Reconnects (non-cold-start) replay all pending events.
|
|
889
|
+
//
|
|
890
|
+
// NOTE: RouteToAgents (active/smart group routing) inserts dispatch rows asynchronously
|
|
891
|
+
// after the message.new WS event. A race exists where the live ack-by-source fires
|
|
892
|
+
// before the dispatch row is inserted, leaving an orphan pending row. This is benign:
|
|
893
|
+
// catch-up will process it on next cycle (duplicate delivery, not data loss).
|
|
894
|
+
// TODO: resolve by creating dispatch rows before publishing the live event, or by
|
|
895
|
+
// consuming dispatch.new in the live path.
|
|
896
|
+
async function catchUpFromDispatch(coldStart = false) {
|
|
869
897
|
const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
|
|
870
898
|
let cursor: string | undefined;
|
|
871
899
|
let processed = 0;
|
|
872
900
|
let skippedOld = 0;
|
|
873
901
|
|
|
874
902
|
do {
|
|
875
|
-
const page = await client.
|
|
876
|
-
type: "mention,agent_dm,task_assign",
|
|
877
|
-
read: "false",
|
|
903
|
+
const page = await client.getDispatch(config.org_id, {
|
|
878
904
|
limit: 50,
|
|
879
905
|
cursor,
|
|
880
906
|
});
|
|
881
907
|
|
|
882
908
|
for (const item of page.data ?? []) {
|
|
883
|
-
if (minAge > 0 && new Date(item.
|
|
884
|
-
client.
|
|
909
|
+
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
910
|
+
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
885
911
|
skippedOld++;
|
|
886
912
|
continue;
|
|
887
913
|
}
|
|
@@ -889,21 +915,59 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
889
915
|
processed++;
|
|
890
916
|
try {
|
|
891
917
|
let dispatched = false;
|
|
892
|
-
if (item.
|
|
918
|
+
if (item.event_type === "task_assign" && item.task_id) {
|
|
893
919
|
// Task catch-up: fetch full task and dispatch
|
|
894
|
-
|
|
920
|
+
let task: Awaited<ReturnType<typeof client.getTask>> | null = null;
|
|
921
|
+
let taskFetchFailed = false;
|
|
922
|
+
try {
|
|
923
|
+
task = await client.getTask(config.org_id, item.task_id);
|
|
924
|
+
} catch (err: unknown) {
|
|
925
|
+
// Distinguish 404 (task deleted) from transient failures
|
|
926
|
+
const status = (err as { status?: number })?.status;
|
|
927
|
+
if (status === 404) {
|
|
928
|
+
task = null; // task genuinely deleted
|
|
929
|
+
} else {
|
|
930
|
+
taskFetchFailed = true;
|
|
931
|
+
log?.warn(`pond[${ctx.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (taskFetchFailed) {
|
|
935
|
+
continue; // leave pending for next catch-up cycle
|
|
936
|
+
}
|
|
895
937
|
if (task) {
|
|
938
|
+
// Defense-in-depth (D2): verify task is still assigned to this agent
|
|
939
|
+
if (task.assignee_id !== agentUserId) {
|
|
940
|
+
log?.info(`pond[${ctx.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
|
|
941
|
+
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
896
944
|
dispatched = await handleTaskAssignment(task);
|
|
897
945
|
} else {
|
|
898
|
-
dispatched = true; // task deleted —
|
|
946
|
+
dispatched = true; // task genuinely deleted — ack to prevent infinite retry
|
|
899
947
|
}
|
|
900
|
-
} else if (item.source_id && item.chat_id) {
|
|
901
|
-
// Message catch-up
|
|
948
|
+
} else if (item.event_type === "message" && item.source_id && item.chat_id) {
|
|
949
|
+
// Message catch-up: build PondEvent from dispatch source pointer
|
|
902
950
|
if (!tryClaimMessage(item.source_id)) continue;
|
|
903
|
-
|
|
951
|
+
let msg: Awaited<ReturnType<typeof client.getMessage>> | null = null;
|
|
952
|
+
let msgFetchFailed = false;
|
|
953
|
+
try {
|
|
954
|
+
msg = await client.getMessage(item.source_id);
|
|
955
|
+
} catch (err: unknown) {
|
|
956
|
+
const status = (err as { status?: number })?.status;
|
|
957
|
+
if (status === 404) {
|
|
958
|
+
msg = null; // message genuinely deleted
|
|
959
|
+
} else {
|
|
960
|
+
msgFetchFailed = true;
|
|
961
|
+
log?.warn(`pond[${ctx.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (msgFetchFailed) {
|
|
965
|
+
dispatchedMessages.delete(item.source_id);
|
|
966
|
+
continue; // leave pending for next catch-up cycle
|
|
967
|
+
}
|
|
904
968
|
if (!msg || msg.sender_id === agentUserId) {
|
|
905
969
|
dispatchedMessages.delete(item.source_id);
|
|
906
|
-
client.
|
|
970
|
+
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
907
971
|
continue;
|
|
908
972
|
}
|
|
909
973
|
|
|
@@ -911,7 +975,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
911
975
|
if (!chatInfo) {
|
|
912
976
|
try {
|
|
913
977
|
const chat = await client.getChat(config.org_id, item.chat_id);
|
|
914
|
-
chatInfo = { type: chat.type, name: chat.name ?? null };
|
|
978
|
+
chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
|
|
915
979
|
chatInfoMap.set(item.chat_id, chatInfo);
|
|
916
980
|
} catch {
|
|
917
981
|
dispatchedMessages.delete(item.source_id);
|
|
@@ -935,7 +999,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
935
999
|
}
|
|
936
1000
|
if (!body) {
|
|
937
1001
|
dispatchedMessages.delete(item.source_id);
|
|
938
|
-
client.
|
|
1002
|
+
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
939
1003
|
continue;
|
|
940
1004
|
}
|
|
941
1005
|
|
|
@@ -955,17 +1019,17 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
955
1019
|
dispatched = await handleInboundEvent(event);
|
|
956
1020
|
}
|
|
957
1021
|
if (dispatched) {
|
|
958
|
-
client.
|
|
1022
|
+
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
959
1023
|
}
|
|
960
1024
|
} catch (err) {
|
|
961
|
-
log?.warn(`pond[${ctx.accountId}]: catch-up
|
|
1025
|
+
log?.warn(`pond[${ctx.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
|
|
962
1026
|
}
|
|
963
1027
|
}
|
|
964
1028
|
cursor = page.has_more ? page.next_cursor : undefined;
|
|
965
1029
|
} while (cursor);
|
|
966
1030
|
|
|
967
1031
|
if (processed > 0 || skippedOld > 0) {
|
|
968
|
-
log?.info(`pond[${ctx.accountId}]:
|
|
1032
|
+
log?.info(`pond[${ctx.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
|
|
969
1033
|
}
|
|
970
1034
|
}
|
|
971
1035
|
|