@yanhaidao/wecom 2.3.4 → 2.3.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/README.md +213 -339
- package/assets/03.bot.page.png +0 -0
- package/changelog/v2.3.9.md +22 -0
- package/compat-single-account.md +32 -2
- package/index.ts +5 -5
- package/package.json +8 -7
- package/src/agent/api-client.upload.test.ts +1 -2
- package/src/agent/handler.ts +82 -9
- package/src/agent/index.ts +1 -1
- package/src/app/account-runtime.ts +245 -0
- package/src/app/bootstrap.ts +29 -0
- package/src/app/index.ts +31 -0
- package/src/capability/agent/delivery-service.ts +79 -0
- package/src/capability/agent/fallback-policy.ts +13 -0
- package/src/capability/agent/index.ts +3 -0
- package/src/capability/agent/ingress-service.ts +38 -0
- package/src/capability/bot/dispatch-config.ts +47 -0
- package/src/capability/bot/fallback-delivery.ts +178 -0
- package/src/capability/bot/index.ts +1 -0
- package/src/capability/bot/local-path-delivery.ts +215 -0
- package/src/capability/bot/service.ts +56 -0
- package/src/capability/bot/stream-delivery.ts +379 -0
- package/src/capability/bot/stream-finalizer.ts +120 -0
- package/src/capability/bot/stream-orchestrator.ts +352 -0
- package/src/capability/bot/types.ts +8 -0
- package/src/capability/index.ts +2 -0
- package/src/channel.lifecycle.test.ts +9 -6
- package/src/channel.meta.test.ts +12 -0
- package/src/channel.ts +48 -21
- package/src/config/accounts.ts +223 -283
- package/src/config/derived-paths.test.ts +111 -0
- package/src/config/derived-paths.ts +41 -0
- package/src/config/index.ts +10 -12
- package/src/config/runtime-config.ts +46 -0
- package/src/config/schema.ts +59 -102
- package/src/domain/models.ts +7 -0
- package/src/domain/policies.ts +36 -0
- package/src/dynamic-agent.ts +6 -0
- package/src/gateway-monitor.ts +43 -93
- package/src/http.ts +23 -2
- package/src/monitor/limits.ts +7 -0
- package/src/monitor/state.ts +28 -508
- package/src/monitor.active.test.ts +3 -3
- package/src/monitor.integration.test.ts +0 -1
- package/src/monitor.ts +64 -2603
- package/src/monitor.webhook.test.ts +127 -42
- package/src/observability/audit-log.ts +48 -0
- package/src/observability/legacy-operational-event-store.ts +36 -0
- package/src/observability/raw-envelope-log.ts +28 -0
- package/src/observability/status-registry.ts +13 -0
- package/src/observability/transport-session-view.ts +14 -0
- package/src/onboarding.test.ts +219 -0
- package/src/onboarding.ts +88 -71
- package/src/outbound.test.ts +5 -5
- package/src/outbound.ts +18 -66
- package/src/runtime/dispatcher.ts +52 -0
- package/src/runtime/index.ts +4 -0
- package/src/runtime/outbound-intent.ts +4 -0
- package/src/runtime/reply-orchestrator.test.ts +38 -0
- package/src/runtime/reply-orchestrator.ts +55 -0
- package/src/runtime/routing-bridge.ts +19 -0
- package/src/runtime/session-manager.ts +76 -0
- package/src/runtime.ts +7 -14
- package/src/shared/command-auth.ts +1 -17
- package/src/shared/media-service.ts +36 -0
- package/src/shared/media-types.ts +5 -0
- package/src/store/active-reply-store.ts +42 -0
- package/src/store/interfaces.ts +11 -0
- package/src/store/memory-store.ts +43 -0
- package/src/store/stream-batch-store.ts +350 -0
- package/src/target.ts +28 -0
- package/src/transport/agent-api/client.ts +44 -0
- package/src/transport/agent-api/core.ts +367 -0
- package/src/transport/agent-api/delivery.ts +41 -0
- package/src/transport/agent-api/media-upload.ts +11 -0
- package/src/transport/agent-api/reply.ts +39 -0
- package/src/transport/agent-callback/http-handler.ts +47 -0
- package/src/transport/agent-callback/inbound.ts +5 -0
- package/src/transport/agent-callback/reply.ts +13 -0
- package/src/transport/agent-callback/request-handler.ts +244 -0
- package/src/transport/agent-callback/session.ts +23 -0
- package/src/transport/bot-webhook/active-reply.ts +36 -0
- package/src/transport/bot-webhook/http-handler.ts +48 -0
- package/src/transport/bot-webhook/inbound-normalizer.ts +371 -0
- package/src/transport/bot-webhook/inbound.ts +5 -0
- package/src/transport/bot-webhook/message-shape.ts +89 -0
- package/src/transport/bot-webhook/protocol.ts +148 -0
- package/src/transport/bot-webhook/reply.ts +15 -0
- package/src/transport/bot-webhook/request-handler.ts +394 -0
- package/src/transport/bot-webhook/session.ts +23 -0
- package/src/transport/bot-ws/inbound.ts +109 -0
- package/src/transport/bot-ws/reply.ts +48 -0
- package/src/transport/bot-ws/sdk-adapter.ts +180 -0
- package/src/transport/bot-ws/session.ts +28 -0
- package/src/transport/http/common.ts +109 -0
- package/src/transport/http/registry.ts +92 -0
- package/src/transport/http/request-handler.ts +84 -0
- package/src/transport/index.ts +14 -0
- package/src/types/account.ts +56 -91
- package/src/types/config.ts +59 -112
- package/src/types/constants.ts +20 -35
- package/src/types/events.ts +21 -0
- package/src/types/index.ts +14 -38
- package/src/types/legacy-stream.ts +50 -0
- package/src/types/runtime-context.ts +28 -0
- package/src/types/runtime.ts +161 -0
- package/src/agent/api-client.ts +0 -383
- package/src/monitor/types.ts +0 -136
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
import { resolveRuntimeRoute } from "./routing-bridge.js";
|
|
4
|
+
import type { UnifiedInboundEvent } from "../types/index.js";
|
|
5
|
+
import type { WecomMediaService } from "../shared/media-service.js";
|
|
6
|
+
|
|
7
|
+
export type PreparedSession = {
|
|
8
|
+
route: ReturnType<typeof resolveRuntimeRoute>;
|
|
9
|
+
ctx: ReturnType<PluginRuntime["channel"]["reply"]["finalizeInboundContext"]>;
|
|
10
|
+
storePath: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function prepareInboundSession(params: {
|
|
14
|
+
core: PluginRuntime;
|
|
15
|
+
cfg: OpenClawConfig;
|
|
16
|
+
event: UnifiedInboundEvent;
|
|
17
|
+
mediaService: WecomMediaService;
|
|
18
|
+
}): Promise<PreparedSession> {
|
|
19
|
+
const { core, cfg, event, mediaService } = params;
|
|
20
|
+
const route = resolveRuntimeRoute({ core, cfg, event });
|
|
21
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
22
|
+
agentId: route.agentId,
|
|
23
|
+
});
|
|
24
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
25
|
+
storePath,
|
|
26
|
+
sessionKey: route.sessionKey,
|
|
27
|
+
});
|
|
28
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
29
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
30
|
+
channel: "WeCom",
|
|
31
|
+
from: `${event.conversation.peerKind}:${event.conversation.peerId}`,
|
|
32
|
+
previousTimestamp,
|
|
33
|
+
envelope: envelopeOptions,
|
|
34
|
+
body: event.text,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const firstAttachment = await mediaService.normalizeFirstAttachment(event);
|
|
38
|
+
const mediaPath = firstAttachment
|
|
39
|
+
? await mediaService.saveInboundAttachment(event, firstAttachment)
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
const ctx = core.channel.reply.finalizeInboundContext({
|
|
43
|
+
Body: body,
|
|
44
|
+
RawBody: event.text,
|
|
45
|
+
CommandBody: event.text,
|
|
46
|
+
From:
|
|
47
|
+
event.conversation.peerKind === "group"
|
|
48
|
+
? `wecom:group:${event.conversation.peerId}`
|
|
49
|
+
: `wecom:${event.conversation.senderId}`,
|
|
50
|
+
To: `wecom:${event.conversation.peerId}`,
|
|
51
|
+
SessionKey: route.sessionKey,
|
|
52
|
+
AccountId: route.accountId,
|
|
53
|
+
ChatType: event.conversation.peerKind,
|
|
54
|
+
ConversationLabel: `${event.conversation.peerKind}:${event.conversation.peerId}`,
|
|
55
|
+
SenderName: event.senderName ?? event.conversation.senderId,
|
|
56
|
+
SenderId: event.conversation.senderId,
|
|
57
|
+
Provider: "wecom",
|
|
58
|
+
Surface: "wecom",
|
|
59
|
+
OriginatingChannel: "wecom",
|
|
60
|
+
OriginatingTo: `wecom:${event.conversation.peerId}`,
|
|
61
|
+
MessageSid: event.messageId,
|
|
62
|
+
CommandAuthorized: true,
|
|
63
|
+
MediaPath: mediaPath,
|
|
64
|
+
MediaUrl: mediaPath,
|
|
65
|
+
MediaType: firstAttachment?.contentType,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await core.channel.session.recordInboundSession({
|
|
69
|
+
storePath,
|
|
70
|
+
sessionKey: ctx.SessionKey ?? route.sessionKey,
|
|
71
|
+
ctx,
|
|
72
|
+
onRecordError: () => {},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { route, ctx, storePath };
|
|
76
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getWecomRuntime(): PluginRuntime {
|
|
10
|
-
if (!runtime) {
|
|
11
|
-
throw new Error("WeCom runtime not initialized");
|
|
12
|
-
}
|
|
13
|
-
return runtime;
|
|
14
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
getAccountRuntimeSnapshot,
|
|
3
|
+
getWecomRuntime,
|
|
4
|
+
registerAccountRuntime,
|
|
5
|
+
setWecomRuntime,
|
|
6
|
+
unregisterAccountRuntime,
|
|
7
|
+
} from "./app/index.js";
|
|
@@ -1,26 +1,10 @@
|
|
|
1
1
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
2
|
|
|
3
3
|
import type { WecomAgentConfig, WecomBotConfig } from "../types/index.js";
|
|
4
|
+
import { isWecomSenderAllowed, normalizeWecomAllowFromEntry } from "../domain/policies.js";
|
|
4
5
|
|
|
5
6
|
type WecomCommandAuthAccountConfig = Pick<WecomBotConfig, "dm"> | Pick<WecomAgentConfig, "dm">;
|
|
6
7
|
|
|
7
|
-
function normalizeWecomAllowFromEntry(raw: string): string {
|
|
8
|
-
return raw
|
|
9
|
-
.trim()
|
|
10
|
-
.toLowerCase()
|
|
11
|
-
.replace(/^wecom:/, "")
|
|
12
|
-
.replace(/^user:/, "")
|
|
13
|
-
.replace(/^userid:/, "");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function isWecomSenderAllowed(senderUserId: string, allowFrom: string[]): boolean {
|
|
17
|
-
const list = allowFrom.map((entry) => normalizeWecomAllowFromEntry(entry)).filter(Boolean);
|
|
18
|
-
if (list.includes("*")) return true;
|
|
19
|
-
const normalizedSender = normalizeWecomAllowFromEntry(senderUserId);
|
|
20
|
-
if (!normalizedSender) return false;
|
|
21
|
-
return list.includes(normalizedSender);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
8
|
export async function resolveWecomCommandAuthorization(params: {
|
|
25
9
|
core: PluginRuntime;
|
|
26
10
|
cfg: OpenClawConfig;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
import type { NormalizedMediaAttachment } from "./media-types.js";
|
|
4
|
+
import type { UnifiedInboundEvent } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
export class WecomMediaService {
|
|
7
|
+
constructor(private readonly core: PluginRuntime) {}
|
|
8
|
+
|
|
9
|
+
async downloadRemoteMedia(params: { url: string }): Promise<NormalizedMediaAttachment> {
|
|
10
|
+
const loaded = await this.core.channel.media.fetchRemoteMedia({ url: params.url });
|
|
11
|
+
return {
|
|
12
|
+
buffer: loaded.buffer,
|
|
13
|
+
contentType: loaded.contentType,
|
|
14
|
+
filename: loaded.fileName,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async saveInboundAttachment(event: UnifiedInboundEvent, attachment: NormalizedMediaAttachment): Promise<string> {
|
|
19
|
+
const saved = await this.core.channel.media.saveMediaBuffer(
|
|
20
|
+
attachment.buffer,
|
|
21
|
+
attachment.contentType,
|
|
22
|
+
"inbound",
|
|
23
|
+
undefined,
|
|
24
|
+
attachment.filename,
|
|
25
|
+
);
|
|
26
|
+
return saved.path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async normalizeFirstAttachment(event: UnifiedInboundEvent): Promise<NormalizedMediaAttachment | undefined> {
|
|
30
|
+
const first = event.attachments?.[0];
|
|
31
|
+
if (!first?.remoteUrl) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return this.downloadRemoteMedia({ url: first.remoteUrl });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { LIMITS } from "../monitor/limits.js";
|
|
2
|
+
import type { ActiveReplyState } from "../types/legacy-stream.js";
|
|
3
|
+
|
|
4
|
+
export class ActiveReplyStore {
|
|
5
|
+
private activeReplies = new Map<string, ActiveReplyState>();
|
|
6
|
+
|
|
7
|
+
constructor(private policy: "once" | "multi" = "once") {}
|
|
8
|
+
|
|
9
|
+
store(streamId: string, responseUrl?: string, proxyUrl?: string): void {
|
|
10
|
+
const url = responseUrl?.trim();
|
|
11
|
+
if (!url) return;
|
|
12
|
+
this.activeReplies.set(streamId, { response_url: url, proxyUrl, createdAt: Date.now() });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getUrl(streamId: string): string | undefined {
|
|
16
|
+
return this.activeReplies.get(streamId)?.response_url;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async use(streamId: string, fn: (params: { responseUrl: string; proxyUrl?: string }) => Promise<void>): Promise<void> {
|
|
20
|
+
const state = this.activeReplies.get(streamId);
|
|
21
|
+
if (!state?.response_url) return;
|
|
22
|
+
if (this.policy === "once" && state.usedAt) {
|
|
23
|
+
throw new Error(`response_url already used for stream ${streamId} (Policy: once)`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await fn({ responseUrl: state.response_url, proxyUrl: state.proxyUrl });
|
|
27
|
+
state.usedAt = Date.now();
|
|
28
|
+
} catch (err: unknown) {
|
|
29
|
+
state.lastError = err instanceof Error ? err.message : String(err);
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
prune(now: number = Date.now()): void {
|
|
35
|
+
const cutoff = now - LIMITS.ACTIVE_REPLY_TTL_MS;
|
|
36
|
+
for (const [id, state] of this.activeReplies.entries()) {
|
|
37
|
+
if (state.createdAt < cutoff) {
|
|
38
|
+
this.activeReplies.delete(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DeliveryTask, ReplyContext, TransportSessionSnapshot, UnifiedInboundEvent } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
export type RuntimeStore = {
|
|
4
|
+
markInboundSeen: (event: UnifiedInboundEvent) => boolean;
|
|
5
|
+
readReplyContext: (messageId: string) => ReplyContext | undefined;
|
|
6
|
+
writeReplyContext: (messageId: string, context: ReplyContext) => void;
|
|
7
|
+
readTransportSession: (accountId: string, transport: TransportSessionSnapshot["transport"]) => TransportSessionSnapshot | undefined;
|
|
8
|
+
writeTransportSession: (snapshot: TransportSessionSnapshot) => void;
|
|
9
|
+
writeDeliveryTask: (task: DeliveryTask) => void;
|
|
10
|
+
readDeliveryTask: (messageId: string) => DeliveryTask | undefined;
|
|
11
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RuntimeStore } from "./interfaces.js";
|
|
2
|
+
import type { DeliveryTask, ReplyContext, TransportSessionSnapshot, UnifiedInboundEvent } from "../types/index.js";
|
|
3
|
+
import { buildDedupKey } from "../domain/policies.js";
|
|
4
|
+
|
|
5
|
+
export class InMemoryRuntimeStore implements RuntimeStore {
|
|
6
|
+
private readonly seen = new Set<string>();
|
|
7
|
+
private readonly replyContexts = new Map<string, ReplyContext>();
|
|
8
|
+
private readonly transportSessions = new Map<string, TransportSessionSnapshot>();
|
|
9
|
+
private readonly deliveryTasks = new Map<string, DeliveryTask>();
|
|
10
|
+
|
|
11
|
+
markInboundSeen(event: UnifiedInboundEvent): boolean {
|
|
12
|
+
const key = buildDedupKey(event);
|
|
13
|
+
if (this.seen.has(key)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
this.seen.add(key);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
readReplyContext(messageId: string): ReplyContext | undefined {
|
|
21
|
+
return this.replyContexts.get(messageId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
writeReplyContext(messageId: string, context: ReplyContext): void {
|
|
25
|
+
this.replyContexts.set(messageId, context);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
readTransportSession(accountId: string, transport: TransportSessionSnapshot["transport"]): TransportSessionSnapshot | undefined {
|
|
29
|
+
return this.transportSessions.get(`${accountId}:${transport}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
writeTransportSession(snapshot: TransportSessionSnapshot): void {
|
|
33
|
+
this.transportSessions.set(`${snapshot.accountId}:${snapshot.transport}`, snapshot);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
writeDeliveryTask(task: DeliveryTask): void {
|
|
37
|
+
this.deliveryTasks.set(task.messageId, task);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
readDeliveryTask(messageId: string): DeliveryTask | undefined {
|
|
41
|
+
return this.deliveryTasks.get(messageId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { LIMITS } from "../monitor/limits.js";
|
|
4
|
+
import type { PendingInbound, StreamState } from "../types/legacy-stream.js";
|
|
5
|
+
import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
|
|
6
|
+
import type { WecomWebhookTarget } from "../types/runtime-context.js";
|
|
7
|
+
|
|
8
|
+
export class StreamStore {
|
|
9
|
+
private streams = new Map<string, StreamState>();
|
|
10
|
+
private msgidToStreamId = new Map<string, string>();
|
|
11
|
+
private pendingInbounds = new Map<string, PendingInbound>();
|
|
12
|
+
private conversationState = new Map<string, { activeBatchKey: string; queue: string[]; nextSeq: number }>();
|
|
13
|
+
private streamIdToBatchKey = new Map<string, string>();
|
|
14
|
+
private batchKeyToStreamIds = new Map<string, Set<string>>();
|
|
15
|
+
private batchStreamIdToAckStreamIds = new Map<string, string[]>();
|
|
16
|
+
private onFlush?: (pending: PendingInbound) => void;
|
|
17
|
+
|
|
18
|
+
public setFlushHandler(handler: (pending: PendingInbound) => void) {
|
|
19
|
+
this.onFlush = handler;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private linkStreamToBatch(streamId: string, batchKey?: string): void {
|
|
23
|
+
const key = String(batchKey ?? "").trim();
|
|
24
|
+
if (!key) return;
|
|
25
|
+
this.streamIdToBatchKey.set(streamId, key);
|
|
26
|
+
const linked = this.batchKeyToStreamIds.get(key) ?? new Set<string>();
|
|
27
|
+
linked.add(streamId);
|
|
28
|
+
this.batchKeyToStreamIds.set(key, linked);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private unlinkStreamFromBatch(streamId: string, batchKey?: string): void {
|
|
32
|
+
const key = String(batchKey ?? this.streamIdToBatchKey.get(streamId) ?? "").trim();
|
|
33
|
+
this.streamIdToBatchKey.delete(streamId);
|
|
34
|
+
if (!key) return;
|
|
35
|
+
const linked = this.batchKeyToStreamIds.get(key);
|
|
36
|
+
if (!linked) return;
|
|
37
|
+
linked.delete(streamId);
|
|
38
|
+
if (linked.size === 0) {
|
|
39
|
+
this.batchKeyToStreamIds.delete(key);
|
|
40
|
+
} else {
|
|
41
|
+
this.batchKeyToStreamIds.set(key, linked);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private hasLiveBatchKey(batchKey: string): boolean {
|
|
46
|
+
const key = batchKey.trim();
|
|
47
|
+
if (!key) return false;
|
|
48
|
+
return this.pendingInbounds.has(key) || (this.batchKeyToStreamIds.get(key)?.size ?? 0) > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private clearPendingTimer(pending?: PendingInbound): void {
|
|
52
|
+
if (!pending?.timeout) return;
|
|
53
|
+
clearTimeout(pending.timeout);
|
|
54
|
+
pending.timeout = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private removePendingBatch(batchKey: string): PendingInbound | undefined {
|
|
58
|
+
const pending = this.pendingInbounds.get(batchKey);
|
|
59
|
+
if (!pending) return undefined;
|
|
60
|
+
this.pendingInbounds.delete(batchKey);
|
|
61
|
+
this.clearPendingTimer(pending);
|
|
62
|
+
pending.readyToFlush = false;
|
|
63
|
+
return pending;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private removeStreamRecord(streamId: string, state?: StreamState): void {
|
|
67
|
+
const current = state ?? this.streams.get(streamId);
|
|
68
|
+
if (!current) return;
|
|
69
|
+
this.streams.delete(streamId);
|
|
70
|
+
this.unlinkStreamFromBatch(streamId, current.batchKey);
|
|
71
|
+
if (current.msgid && this.msgidToStreamId.get(current.msgid) === streamId) {
|
|
72
|
+
this.msgidToStreamId.delete(current.msgid);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private pruneAckStreamMappings(): void {
|
|
77
|
+
for (const [batchStreamId, ackIds] of this.batchStreamIdToAckStreamIds.entries()) {
|
|
78
|
+
if (!this.streams.has(batchStreamId)) {
|
|
79
|
+
this.batchStreamIdToAckStreamIds.delete(batchStreamId);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const nextAckIds = ackIds.filter((ackId) => this.streams.has(ackId));
|
|
83
|
+
if (nextAckIds.length === 0) {
|
|
84
|
+
this.batchStreamIdToAckStreamIds.delete(batchStreamId);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
this.batchStreamIdToAckStreamIds.set(batchStreamId, nextAckIds);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private pruneConversationState(): void {
|
|
92
|
+
for (const [convKey, conv] of this.conversationState.entries()) {
|
|
93
|
+
conv.queue = conv.queue.filter((batchKey) => this.pendingInbounds.has(batchKey));
|
|
94
|
+
if (!this.hasLiveBatchKey(conv.activeBatchKey)) {
|
|
95
|
+
const next = conv.queue.shift();
|
|
96
|
+
if (!next) {
|
|
97
|
+
this.conversationState.delete(convKey);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
conv.activeBatchKey = next;
|
|
101
|
+
}
|
|
102
|
+
this.conversationState.set(convKey, conv);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
createStream(params: { msgid?: string; conversationKey?: string; batchKey?: string }): string {
|
|
107
|
+
const streamId = crypto.randomBytes(16).toString("hex");
|
|
108
|
+
if (params.msgid) {
|
|
109
|
+
this.msgidToStreamId.set(String(params.msgid), streamId);
|
|
110
|
+
}
|
|
111
|
+
this.streams.set(streamId, {
|
|
112
|
+
streamId,
|
|
113
|
+
msgid: params.msgid,
|
|
114
|
+
conversationKey: params.conversationKey,
|
|
115
|
+
batchKey: params.batchKey,
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
updatedAt: Date.now(),
|
|
118
|
+
started: false,
|
|
119
|
+
finished: false,
|
|
120
|
+
content: "",
|
|
121
|
+
});
|
|
122
|
+
this.linkStreamToBatch(streamId, params.batchKey);
|
|
123
|
+
return streamId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getStream(streamId: string): StreamState | undefined {
|
|
127
|
+
return this.streams.get(streamId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getStreamByMsgId(msgid: string): string | undefined {
|
|
131
|
+
return this.msgidToStreamId.get(String(msgid));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setStreamIdForMsgId(msgid: string, streamId: string): void {
|
|
135
|
+
const key = String(msgid).trim();
|
|
136
|
+
const value = String(streamId).trim();
|
|
137
|
+
if (!key || !value) return;
|
|
138
|
+
this.msgidToStreamId.set(key, value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
addAckStreamForBatch(params: { batchStreamId: string; ackStreamId: string }): void {
|
|
142
|
+
const batchStreamId = params.batchStreamId.trim();
|
|
143
|
+
const ackStreamId = params.ackStreamId.trim();
|
|
144
|
+
if (!batchStreamId || !ackStreamId) return;
|
|
145
|
+
const list = this.batchStreamIdToAckStreamIds.get(batchStreamId) ?? [];
|
|
146
|
+
list.push(ackStreamId);
|
|
147
|
+
this.batchStreamIdToAckStreamIds.set(batchStreamId, list);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
drainAckStreamsForBatch(batchStreamId: string): string[] {
|
|
151
|
+
const key = batchStreamId.trim();
|
|
152
|
+
if (!key) return [];
|
|
153
|
+
const list = this.batchStreamIdToAckStreamIds.get(key) ?? [];
|
|
154
|
+
this.batchStreamIdToAckStreamIds.delete(key);
|
|
155
|
+
return list;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
updateStream(streamId: string, mutator: (state: StreamState) => void): void {
|
|
159
|
+
const state = this.streams.get(streamId);
|
|
160
|
+
if (state) {
|
|
161
|
+
mutator(state);
|
|
162
|
+
state.updatedAt = Date.now();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
markStarted(streamId: string): void {
|
|
167
|
+
this.updateStream(streamId, (s) => {
|
|
168
|
+
s.started = true;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
markFinished(streamId: string): void {
|
|
173
|
+
this.updateStream(streamId, (s) => {
|
|
174
|
+
s.finished = true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
addPendingMessage(params: {
|
|
179
|
+
conversationKey: string;
|
|
180
|
+
target: WecomWebhookTarget;
|
|
181
|
+
msg: WecomInboundMessage;
|
|
182
|
+
msgContent: string;
|
|
183
|
+
nonce: string;
|
|
184
|
+
timestamp: string;
|
|
185
|
+
debounceMs?: number;
|
|
186
|
+
}): { streamId: string; status: "active_new" | "active_merged" | "queued_new" | "queued_merged" } {
|
|
187
|
+
const { conversationKey, target, msg, msgContent, nonce, timestamp, debounceMs } = params;
|
|
188
|
+
const effectiveDebounceMs = debounceMs ?? LIMITS.DEFAULT_DEBOUNCE_MS;
|
|
189
|
+
|
|
190
|
+
const state = this.conversationState.get(conversationKey);
|
|
191
|
+
if (!state) {
|
|
192
|
+
const batchKey = conversationKey;
|
|
193
|
+
const streamId = this.createStream({ msgid: msg.msgid, conversationKey, batchKey });
|
|
194
|
+
const pending: PendingInbound = {
|
|
195
|
+
streamId,
|
|
196
|
+
conversationKey,
|
|
197
|
+
batchKey,
|
|
198
|
+
target,
|
|
199
|
+
msg,
|
|
200
|
+
contents: [msgContent],
|
|
201
|
+
msgids: msg.msgid ? [msg.msgid] : [],
|
|
202
|
+
nonce,
|
|
203
|
+
timestamp,
|
|
204
|
+
createdAt: Date.now(),
|
|
205
|
+
timeout: setTimeout(() => {
|
|
206
|
+
this.requestFlush(batchKey);
|
|
207
|
+
}, effectiveDebounceMs),
|
|
208
|
+
};
|
|
209
|
+
this.pendingInbounds.set(batchKey, pending);
|
|
210
|
+
this.conversationState.set(conversationKey, { activeBatchKey: batchKey, queue: [], nextSeq: 1 });
|
|
211
|
+
return { streamId, status: "active_new" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const activeBatchKey = state.activeBatchKey;
|
|
215
|
+
const activeIsInitial = activeBatchKey === conversationKey;
|
|
216
|
+
const activePending = this.pendingInbounds.get(activeBatchKey);
|
|
217
|
+
if (activePending && !activeIsInitial) {
|
|
218
|
+
const activeStream = this.streams.get(activePending.streamId);
|
|
219
|
+
const activeStarted = Boolean(activeStream?.started);
|
|
220
|
+
if (!activeStarted) {
|
|
221
|
+
activePending.contents.push(msgContent);
|
|
222
|
+
if (msg.msgid) {
|
|
223
|
+
activePending.msgids.push(msg.msgid);
|
|
224
|
+
}
|
|
225
|
+
if (activePending.timeout) clearTimeout(activePending.timeout);
|
|
226
|
+
activePending.timeout = setTimeout(() => {
|
|
227
|
+
this.requestFlush(activeBatchKey);
|
|
228
|
+
}, effectiveDebounceMs);
|
|
229
|
+
return { streamId: activePending.streamId, status: "active_merged" };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const queuedBatchKey = state.queue[0];
|
|
234
|
+
if (queuedBatchKey) {
|
|
235
|
+
const existingQueued = this.pendingInbounds.get(queuedBatchKey);
|
|
236
|
+
if (existingQueued) {
|
|
237
|
+
existingQueued.contents.push(msgContent);
|
|
238
|
+
if (msg.msgid) {
|
|
239
|
+
existingQueued.msgids.push(msg.msgid);
|
|
240
|
+
}
|
|
241
|
+
if (existingQueued.timeout) clearTimeout(existingQueued.timeout);
|
|
242
|
+
existingQueued.timeout = setTimeout(() => {
|
|
243
|
+
this.requestFlush(queuedBatchKey);
|
|
244
|
+
}, effectiveDebounceMs);
|
|
245
|
+
return { streamId: existingQueued.streamId, status: "queued_merged" };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const seq = state.nextSeq++;
|
|
250
|
+
const batchKey = `${conversationKey}#q${seq}`;
|
|
251
|
+
state.queue = [batchKey];
|
|
252
|
+
const streamId = this.createStream({ msgid: msg.msgid, conversationKey, batchKey });
|
|
253
|
+
const pending: PendingInbound = {
|
|
254
|
+
streamId,
|
|
255
|
+
conversationKey,
|
|
256
|
+
batchKey,
|
|
257
|
+
target,
|
|
258
|
+
msg,
|
|
259
|
+
contents: [msgContent],
|
|
260
|
+
msgids: msg.msgid ? [msg.msgid] : [],
|
|
261
|
+
nonce,
|
|
262
|
+
timestamp,
|
|
263
|
+
createdAt: Date.now(),
|
|
264
|
+
timeout: setTimeout(() => {
|
|
265
|
+
this.requestFlush(batchKey);
|
|
266
|
+
}, effectiveDebounceMs),
|
|
267
|
+
};
|
|
268
|
+
this.pendingInbounds.set(batchKey, pending);
|
|
269
|
+
this.conversationState.set(conversationKey, state);
|
|
270
|
+
return { streamId, status: "queued_new" };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private requestFlush(batchKey: string): void {
|
|
274
|
+
const pending = this.pendingInbounds.get(batchKey);
|
|
275
|
+
if (!pending) return;
|
|
276
|
+
|
|
277
|
+
const state = this.conversationState.get(pending.conversationKey);
|
|
278
|
+
const isActive = state?.activeBatchKey === batchKey;
|
|
279
|
+
if (!isActive) {
|
|
280
|
+
if (pending.timeout) {
|
|
281
|
+
clearTimeout(pending.timeout);
|
|
282
|
+
pending.timeout = null;
|
|
283
|
+
}
|
|
284
|
+
pending.readyToFlush = true;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.flushPending(batchKey);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private flushPending(pendingKey: string): void {
|
|
291
|
+
const pending = this.removePendingBatch(pendingKey);
|
|
292
|
+
if (!pending) return;
|
|
293
|
+
|
|
294
|
+
if (this.onFlush) {
|
|
295
|
+
this.onFlush(pending);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
onStreamFinished(streamId: string): void {
|
|
300
|
+
const batchKey = this.streamIdToBatchKey.get(streamId);
|
|
301
|
+
const state = batchKey ? this.streams.get(streamId) : undefined;
|
|
302
|
+
const conversationKey = state?.conversationKey;
|
|
303
|
+
if (!batchKey || !conversationKey) return;
|
|
304
|
+
|
|
305
|
+
this.unlinkStreamFromBatch(streamId, batchKey);
|
|
306
|
+
|
|
307
|
+
const conv = this.conversationState.get(conversationKey);
|
|
308
|
+
if (!conv) return;
|
|
309
|
+
if (conv.activeBatchKey !== batchKey) return;
|
|
310
|
+
|
|
311
|
+
const next = conv.queue.shift();
|
|
312
|
+
if (!next) {
|
|
313
|
+
this.conversationState.delete(conversationKey);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
conv.activeBatchKey = next;
|
|
317
|
+
this.conversationState.set(conversationKey, conv);
|
|
318
|
+
|
|
319
|
+
const pending = this.pendingInbounds.get(next);
|
|
320
|
+
if (!pending) return;
|
|
321
|
+
if (pending.readyToFlush) {
|
|
322
|
+
this.flushPending(next);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
prune(now: number = Date.now()): void {
|
|
327
|
+
const streamCutoff = now - LIMITS.STREAM_TTL_MS;
|
|
328
|
+
|
|
329
|
+
for (const [id, state] of this.streams.entries()) {
|
|
330
|
+
if (state.updatedAt < streamCutoff) {
|
|
331
|
+
this.removeStreamRecord(id, state);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const [msgid, id] of this.msgidToStreamId.entries()) {
|
|
336
|
+
if (!this.streams.has(id)) {
|
|
337
|
+
this.msgidToStreamId.delete(msgid);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const [key, pending] of this.pendingInbounds.entries()) {
|
|
342
|
+
if (now - pending.createdAt > LIMITS.STREAM_TTL_MS) {
|
|
343
|
+
this.removePendingBatch(key);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.pruneAckStreamMappings();
|
|
348
|
+
this.pruneConversationState();
|
|
349
|
+
}
|
|
350
|
+
}
|
package/src/target.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface WecomTarget {
|
|
|
20
20
|
chatid?: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface ScopedWecomTarget {
|
|
24
|
+
accountId?: string;
|
|
25
|
+
target: WecomTarget;
|
|
26
|
+
rawTarget: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* Parses a raw target string into a WeComTarget object.
|
|
25
31
|
* 解析原始目标字符串为 WeComTarget 对象。
|
|
@@ -78,3 +84,25 @@ export function resolveWecomTarget(raw: string | undefined): WecomTarget | undef
|
|
|
78
84
|
// Default to User (默认为用户)
|
|
79
85
|
return { touser: clean };
|
|
80
86
|
}
|
|
87
|
+
|
|
88
|
+
export function resolveScopedWecomTarget(raw: string | undefined, defaultAccountId?: string): ScopedWecomTarget | undefined {
|
|
89
|
+
if (!raw?.trim()) return undefined;
|
|
90
|
+
|
|
91
|
+
const trimmed = raw.trim();
|
|
92
|
+
const agentScoped = trimmed.match(/^wecom-agent:([^:]+):(.+)$/i);
|
|
93
|
+
if (agentScoped) {
|
|
94
|
+
const accountId = agentScoped[1]?.trim() || defaultAccountId;
|
|
95
|
+
const rawTarget = agentScoped[2]?.trim() || "";
|
|
96
|
+
const target = resolveWecomTarget(rawTarget);
|
|
97
|
+
return target ? { accountId, target, rawTarget } : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const target = resolveWecomTarget(trimmed);
|
|
101
|
+
return target
|
|
102
|
+
? {
|
|
103
|
+
accountId: defaultAccountId,
|
|
104
|
+
target,
|
|
105
|
+
rawTarget: trimmed,
|
|
106
|
+
}
|
|
107
|
+
: undefined;
|
|
108
|
+
}
|