@actagent/feishu 2026.6.2
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 +11 -0
- package/actagent.plugin.json +224 -0
- package/api.ts +33 -0
- package/channel-entry.ts +21 -0
- package/channel-plugin-api.ts +2 -0
- package/contract-api.ts +17 -0
- package/index.ts +83 -0
- package/legacy-state-migrations-api.ts +2 -0
- package/npm-shrinkwrap.json +539 -0
- package/package.json +64 -0
- package/runtime-api.ts +58 -0
- package/runtime-setter-api.ts +3 -0
- package/secret-contract-api.ts +6 -0
- package/security-contract-api.ts +2 -0
- package/session-key-api.ts +2 -0
- package/setup-api.ts +4 -0
- package/setup-entry.test.ts +33 -0
- package/setup-entry.ts +25 -0
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +113 -0
- package/src/accounts.test.ts +481 -0
- package/src/accounts.ts +380 -0
- package/src/agent-config.ts +22 -0
- package/src/app-registration.test.ts +62 -0
- package/src/app-registration.ts +355 -0
- package/src/approval-auth.test.ts +25 -0
- package/src/approval-auth.ts +26 -0
- package/src/async.test.ts +68 -0
- package/src/async.ts +109 -0
- package/src/audio-preflight.runtime.ts +10 -0
- package/src/bitable.test.ts +174 -0
- package/src/bitable.ts +781 -0
- package/src/bot-content.ts +488 -0
- package/src/bot-group-name.test.ts +148 -0
- package/src/bot-runtime-api.ts +13 -0
- package/src/bot-sender-name.test.ts +68 -0
- package/src/bot-sender-name.ts +137 -0
- package/src/bot.broadcast.test.ts +643 -0
- package/src/bot.card-action.test.ts +647 -0
- package/src/bot.checkBotMentioned.test.ts +266 -0
- package/src/bot.helpers.test.ts +136 -0
- package/src/bot.stripBotMention.test.ts +127 -0
- package/src/bot.test.ts +3817 -0
- package/src/bot.ts +1788 -0
- package/src/card-action.ts +515 -0
- package/src/card-interaction.test.ts +132 -0
- package/src/card-interaction.ts +160 -0
- package/src/card-test-helpers.ts +55 -0
- package/src/card-ux-approval.ts +66 -0
- package/src/card-ux-launcher.test.ts +126 -0
- package/src/card-ux-launcher.ts +136 -0
- package/src/card-ux-shared.ts +34 -0
- package/src/channel-runtime-api.ts +17 -0
- package/src/channel.runtime.ts +48 -0
- package/src/channel.test.ts +1337 -0
- package/src/channel.ts +1401 -0
- package/src/chat-schema.ts +30 -0
- package/src/chat.test.ts +295 -0
- package/src/chat.ts +198 -0
- package/src/client-timeout.ts +44 -0
- package/src/client.test.ts +463 -0
- package/src/client.ts +263 -0
- package/src/comment-dispatcher-runtime-api.ts +7 -0
- package/src/comment-dispatcher.test.ts +186 -0
- package/src/comment-dispatcher.ts +108 -0
- package/src/comment-handler-runtime-api.ts +4 -0
- package/src/comment-handler.test.ts +588 -0
- package/src/comment-handler.ts +304 -0
- package/src/comment-reaction.test.ts +139 -0
- package/src/comment-reaction.ts +260 -0
- package/src/comment-shared.test.ts +184 -0
- package/src/comment-shared.ts +405 -0
- package/src/comment-target.ts +45 -0
- package/src/config-schema.test.ts +327 -0
- package/src/config-schema.ts +338 -0
- package/src/conversation-id.test.ts +19 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-migrations.test.ts +90 -0
- package/src/dedup-migrations.ts +103 -0
- package/src/dedup.test.ts +95 -0
- package/src/dedup.ts +304 -0
- package/src/dedupe-key.ts +68 -0
- package/src/directory.static.ts +62 -0
- package/src/directory.test.ts +142 -0
- package/src/directory.ts +125 -0
- package/src/doc-schema.ts +183 -0
- package/src/doctor.test.ts +382 -0
- package/src/doctor.ts +876 -0
- package/src/docx-batch-insert.test.ts +117 -0
- package/src/docx-batch-insert.ts +223 -0
- package/src/docx-color-text.ts +154 -0
- package/src/docx-table-ops.test.ts +54 -0
- package/src/docx-table-ops.ts +316 -0
- package/src/docx-types.ts +39 -0
- package/src/docx.account-selection.test.ts +96 -0
- package/src/docx.test.ts +706 -0
- package/src/docx.ts +1598 -0
- package/src/drive-schema.ts +93 -0
- package/src/drive.test.ts +1240 -0
- package/src/drive.ts +830 -0
- package/src/dynamic-agent.test.ts +156 -0
- package/src/dynamic-agent.ts +144 -0
- package/src/event-types.ts +46 -0
- package/src/external-keys.test.ts +21 -0
- package/src/external-keys.ts +20 -0
- package/src/lifecycle.test-support.ts +223 -0
- package/src/media.test.ts +956 -0
- package/src/media.ts +1106 -0
- package/src/mention-target.types.ts +6 -0
- package/src/mention.ts +115 -0
- package/src/message-action-contract.ts +14 -0
- package/src/monitor-state-runtime-api.ts +8 -0
- package/src/monitor-transport-runtime-api.ts +11 -0
- package/src/monitor.account.ts +501 -0
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +215 -0
- package/src/monitor.bot-identity.ts +87 -0
- package/src/monitor.bot-menu-handler.ts +164 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +221 -0
- package/src/monitor.bot-menu.test.ts +200 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +265 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +418 -0
- package/src/monitor.cleanup.test.ts +384 -0
- package/src/monitor.comment-notice-handler.ts +106 -0
- package/src/monitor.comment.test.ts +968 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +5 -0
- package/src/monitor.message-handler.ts +346 -0
- package/src/monitor.reaction.test.ts +770 -0
- package/src/monitor.startup.test.ts +232 -0
- package/src/monitor.startup.ts +76 -0
- package/src/monitor.state.defaults.test.ts +47 -0
- package/src/monitor.state.ts +171 -0
- package/src/monitor.synthetic-error.ts +19 -0
- package/src/monitor.test-mocks.ts +47 -0
- package/src/monitor.transport.ts +451 -0
- package/src/monitor.ts +104 -0
- package/src/monitor.webhook-e2e.test.ts +284 -0
- package/src/monitor.webhook-security.test.ts +394 -0
- package/src/monitor.webhook.test-helpers.ts +138 -0
- package/src/outbound-runtime-api.ts +2 -0
- package/src/outbound.test.ts +1255 -0
- package/src/outbound.ts +742 -0
- package/src/perm-schema.ts +53 -0
- package/src/perm.ts +171 -0
- package/src/pins.ts +109 -0
- package/src/policy.test.ts +224 -0
- package/src/policy.ts +322 -0
- package/src/post.test.ts +106 -0
- package/src/post.ts +276 -0
- package/src/presentation-card.ts +204 -0
- package/src/probe.test.ts +310 -0
- package/src/probe.ts +181 -0
- package/src/processing-claims.ts +60 -0
- package/src/qr-terminal.ts +2 -0
- package/src/reactions.ts +124 -0
- package/src/reasoning-preview.test.ts +114 -0
- package/src/reasoning-preview.ts +29 -0
- package/src/reply-dispatcher-runtime-api.ts +8 -0
- package/src/reply-dispatcher.test.ts +2009 -0
- package/src/reply-dispatcher.ts +865 -0
- package/src/runtime.ts +10 -0
- package/src/secret-contract.ts +146 -0
- package/src/secret-input.ts +2 -0
- package/src/security-audit-shared.ts +70 -0
- package/src/security-audit.test.ts +60 -0
- package/src/security-audit.ts +2 -0
- package/src/send-result.ts +81 -0
- package/src/send-target.test.ts +87 -0
- package/src/send-target.ts +36 -0
- package/src/send.reply-fallback.test.ts +418 -0
- package/src/send.test.ts +661 -0
- package/src/send.ts +860 -0
- package/src/sequential-key.test.ts +73 -0
- package/src/sequential-key.ts +29 -0
- package/src/sequential-queue.test.ts +184 -0
- package/src/sequential-queue.ts +90 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +49 -0
- package/src/setup-core.ts +52 -0
- package/src/setup-surface.test.ts +485 -0
- package/src/setup-surface.ts +620 -0
- package/src/streaming-card.test.ts +549 -0
- package/src/streaming-card.ts +611 -0
- package/src/subagent-hooks.test.ts +632 -0
- package/src/subagent-hooks.ts +414 -0
- package/src/targets.ts +98 -0
- package/src/test-support/lifecycle-test-support.ts +459 -0
- package/src/thread-bindings.test.ts +181 -0
- package/src/thread-bindings.ts +332 -0
- package/src/tool-account-routing.test.ts +419 -0
- package/src/tool-account.test.ts +45 -0
- package/src/tool-account.ts +98 -0
- package/src/tool-factory-test-harness.ts +83 -0
- package/src/tool-result.test.ts +33 -0
- package/src/tool-result.ts +17 -0
- package/src/tools-config.test.ts +52 -0
- package/src/tools-config.ts +29 -0
- package/src/types.ts +111 -0
- package/src/typing.test.ts +145 -0
- package/src/typing.ts +215 -0
- package/src/wiki-schema.ts +70 -0
- package/src/wiki.ts +271 -0
- package/subagent-hooks-api.ts +22 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Feishu plugin module implements conversation id behavior.
|
|
2
|
+
import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
|
|
3
|
+
|
|
4
|
+
export type FeishuGroupSessionScope =
|
|
5
|
+
| "group"
|
|
6
|
+
| "group_sender"
|
|
7
|
+
| "group_topic"
|
|
8
|
+
| "group_topic_sender";
|
|
9
|
+
|
|
10
|
+
function normalizeText(value: unknown): string | undefined {
|
|
11
|
+
if (typeof value !== "string") {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildFeishuConversationId(params: {
|
|
19
|
+
chatId: string;
|
|
20
|
+
scope: FeishuGroupSessionScope;
|
|
21
|
+
senderOpenId?: string;
|
|
22
|
+
topicId?: string;
|
|
23
|
+
}): string {
|
|
24
|
+
const chatId = normalizeText(params.chatId) ?? "unknown";
|
|
25
|
+
const senderOpenId = normalizeText(params.senderOpenId);
|
|
26
|
+
const topicId = normalizeText(params.topicId);
|
|
27
|
+
|
|
28
|
+
switch (params.scope) {
|
|
29
|
+
case "group_sender":
|
|
30
|
+
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
|
|
31
|
+
case "group_topic":
|
|
32
|
+
return topicId ? `${chatId}:topic:${topicId}` : chatId;
|
|
33
|
+
case "group_topic_sender":
|
|
34
|
+
if (topicId && senderOpenId) {
|
|
35
|
+
return `${chatId}:topic:${topicId}:sender:${senderOpenId}`;
|
|
36
|
+
}
|
|
37
|
+
if (topicId) {
|
|
38
|
+
return `${chatId}:topic:${topicId}`;
|
|
39
|
+
}
|
|
40
|
+
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
|
|
41
|
+
default:
|
|
42
|
+
return chatId;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseFeishuTargetId(raw: unknown): string | undefined {
|
|
47
|
+
const target = normalizeText(raw);
|
|
48
|
+
if (!target) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
|
|
52
|
+
if (!withoutProvider) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const lowered = normalizeLowercaseStringOrEmpty(withoutProvider);
|
|
56
|
+
for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) {
|
|
57
|
+
if (lowered.startsWith(prefix)) {
|
|
58
|
+
return normalizeText(withoutProvider.slice(prefix.length));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return withoutProvider;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseFeishuDirectConversationId(raw: unknown): string | undefined {
|
|
65
|
+
const target = normalizeText(raw);
|
|
66
|
+
if (!target) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
|
|
70
|
+
if (!withoutProvider) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const lowered = normalizeLowercaseStringOrEmpty(withoutProvider);
|
|
74
|
+
for (const prefix of ["user:", "dm:", "open_id:"]) {
|
|
75
|
+
if (lowered.startsWith(prefix)) {
|
|
76
|
+
return normalizeText(withoutProvider.slice(prefix.length));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const id = parseFeishuTargetId(target);
|
|
80
|
+
if (!id) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (id.startsWith("ou_") || id.startsWith("on_")) {
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseFeishuConversationId(params: {
|
|
90
|
+
conversationId: string;
|
|
91
|
+
parentConversationId?: string;
|
|
92
|
+
}): {
|
|
93
|
+
canonicalConversationId: string;
|
|
94
|
+
chatId: string;
|
|
95
|
+
topicId?: string;
|
|
96
|
+
senderOpenId?: string;
|
|
97
|
+
scope: FeishuGroupSessionScope;
|
|
98
|
+
} | null {
|
|
99
|
+
const conversationId = normalizeText(params.conversationId);
|
|
100
|
+
const parentConversationId = normalizeText(params.parentConversationId);
|
|
101
|
+
if (!conversationId) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
|
|
106
|
+
if (topicSenderMatch) {
|
|
107
|
+
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
|
|
108
|
+
return {
|
|
109
|
+
canonicalConversationId: buildFeishuConversationId({
|
|
110
|
+
chatId,
|
|
111
|
+
scope: "group_topic_sender",
|
|
112
|
+
topicId,
|
|
113
|
+
senderOpenId,
|
|
114
|
+
}),
|
|
115
|
+
chatId,
|
|
116
|
+
topicId,
|
|
117
|
+
senderOpenId,
|
|
118
|
+
scope: "group_topic_sender",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/i);
|
|
123
|
+
if (topicMatch) {
|
|
124
|
+
const [, chatId, topicId] = topicMatch;
|
|
125
|
+
return {
|
|
126
|
+
canonicalConversationId: buildFeishuConversationId({
|
|
127
|
+
chatId,
|
|
128
|
+
scope: "group_topic",
|
|
129
|
+
topicId,
|
|
130
|
+
}),
|
|
131
|
+
chatId,
|
|
132
|
+
topicId,
|
|
133
|
+
scope: "group_topic",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/i);
|
|
138
|
+
if (senderMatch) {
|
|
139
|
+
const [, chatId, senderOpenId] = senderMatch;
|
|
140
|
+
return {
|
|
141
|
+
canonicalConversationId: buildFeishuConversationId({
|
|
142
|
+
chatId,
|
|
143
|
+
scope: "group_sender",
|
|
144
|
+
senderOpenId,
|
|
145
|
+
}),
|
|
146
|
+
chatId,
|
|
147
|
+
senderOpenId,
|
|
148
|
+
scope: "group_sender",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (parentConversationId) {
|
|
153
|
+
return {
|
|
154
|
+
canonicalConversationId: buildFeishuConversationId({
|
|
155
|
+
chatId: parentConversationId,
|
|
156
|
+
scope: "group_topic",
|
|
157
|
+
topicId: conversationId,
|
|
158
|
+
}),
|
|
159
|
+
chatId: parentConversationId,
|
|
160
|
+
topicId: conversationId,
|
|
161
|
+
scope: "group_topic",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
canonicalConversationId: conversationId,
|
|
167
|
+
chatId: conversationId,
|
|
168
|
+
scope: "group",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function buildFeishuModelOverrideParentCandidates(
|
|
173
|
+
parentConversationId?: string | null,
|
|
174
|
+
): string[] {
|
|
175
|
+
const rawId = normalizeText(parentConversationId);
|
|
176
|
+
if (!rawId) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
|
|
180
|
+
if (topicSenderMatch) {
|
|
181
|
+
const chatId = normalizeLowercaseStringOrEmpty(topicSenderMatch[1]);
|
|
182
|
+
const topicId = normalizeLowercaseStringOrEmpty(topicSenderMatch[2]);
|
|
183
|
+
if (chatId && topicId) {
|
|
184
|
+
return [`${chatId}:topic:${topicId}`, chatId];
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
|
|
189
|
+
if (topicMatch) {
|
|
190
|
+
const chatId = normalizeLowercaseStringOrEmpty(topicMatch[1]);
|
|
191
|
+
return chatId ? [chatId] : [];
|
|
192
|
+
}
|
|
193
|
+
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
|
|
194
|
+
if (senderMatch) {
|
|
195
|
+
const chatId = normalizeLowercaseStringOrEmpty(senderMatch[1]);
|
|
196
|
+
return chatId ? [chatId] : [];
|
|
197
|
+
}
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Feishu tests cover dedup migrations plugin behavior.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { detectFeishuLegacyStateMigrations } from "./dedup-migrations.js";
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
async function makeStateDir(): Promise<string> {
|
|
11
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "actagent-feishu-dedup-migration-"));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("Feishu dedupe migration", () => {
|
|
22
|
+
it("plans recent legacy dedupe rows with remaining TTL", async () => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
vi.setSystemTime(2_000);
|
|
25
|
+
const stateDir = await makeStateDir();
|
|
26
|
+
const sourcePath = path.join(stateDir, "feishu", "dedup", "account-a.json");
|
|
27
|
+
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
|
28
|
+
await fs.writeFile(
|
|
29
|
+
sourcePath,
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
fresh: 1_000,
|
|
32
|
+
expired: 2_000 - 24 * 60 * 60 * 1000,
|
|
33
|
+
malformed: "nope",
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const plans = await Promise.resolve(
|
|
38
|
+
detectFeishuLegacyStateMigrations({
|
|
39
|
+
cfg: {},
|
|
40
|
+
env: {},
|
|
41
|
+
oauthDir: path.join(stateDir, "credentials"),
|
|
42
|
+
stateDir,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!plans) {
|
|
47
|
+
throw new Error("expected migration plans");
|
|
48
|
+
}
|
|
49
|
+
expect(plans).toHaveLength(1);
|
|
50
|
+
const plan = plans[0];
|
|
51
|
+
expect(plan?.kind).toBe("plugin-state-import");
|
|
52
|
+
if (plan?.kind !== "plugin-state-import") {
|
|
53
|
+
throw new Error("expected plugin-state import plan");
|
|
54
|
+
}
|
|
55
|
+
expect(plan.pluginId).toBe("feishu");
|
|
56
|
+
expect(plan.namespace).toBe("dedup.account-a");
|
|
57
|
+
const entries = await plan.readEntries();
|
|
58
|
+
expect(entries).toHaveLength(1);
|
|
59
|
+
expect(entries[0]?.key).toMatch(/^[0-9a-f]{32}$/u);
|
|
60
|
+
expect(entries[0]?.value).toEqual({
|
|
61
|
+
namespace: "account-a",
|
|
62
|
+
messageId: "fresh",
|
|
63
|
+
seenAt: 1_000,
|
|
64
|
+
});
|
|
65
|
+
expect(entries[0]?.ttlMs).toBe(24 * 60 * 60 * 1000 - 1_000);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("skips expired-only legacy dedupe files", async () => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
vi.setSystemTime(2_000);
|
|
71
|
+
const stateDir = await makeStateDir();
|
|
72
|
+
const sourcePath = path.join(stateDir, "feishu", "dedup", "account-a.json");
|
|
73
|
+
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
|
74
|
+
await fs.writeFile(
|
|
75
|
+
sourcePath,
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
expired: 2_000 - 24 * 60 * 60 * 1000,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
detectFeishuLegacyStateMigrations({
|
|
83
|
+
cfg: {},
|
|
84
|
+
env: {},
|
|
85
|
+
oauthDir: path.join(stateDir, "credentials"),
|
|
86
|
+
stateDir,
|
|
87
|
+
}),
|
|
88
|
+
).toStrictEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Feishu plugin module implements dedup migrations behavior.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { BundledChannelLegacyStateMigrationDetector } from "actagent/plugin-sdk/channel-entry-contract";
|
|
6
|
+
|
|
7
|
+
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const STORE_MAX_ENTRIES = 10_000;
|
|
9
|
+
|
|
10
|
+
type LegacyDedupeData = Record<string, number>;
|
|
11
|
+
|
|
12
|
+
function safeNamespaceFromFileName(fileName: string): string | null {
|
|
13
|
+
if (!fileName.endsWith(".json")) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const namespace = fileName.slice(0, -".json".length).trim();
|
|
17
|
+
return namespace ? namespace : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readLegacyDedupeData(filePath: string): LegacyDedupeData {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
|
23
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
const out: LegacyDedupeData = {};
|
|
27
|
+
for (const [messageId, seenAt] of Object.entries(parsed as Record<string, unknown>)) {
|
|
28
|
+
if (typeof seenAt === "number" && Number.isFinite(seenAt) && seenAt > 0) {
|
|
29
|
+
out[messageId] = seenAt;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function dedupeStoreKey(namespace: string, messageId: string): string {
|
|
39
|
+
return createHash("sha256")
|
|
40
|
+
.update(`${namespace}\0${messageId}`, "utf8")
|
|
41
|
+
.digest("hex")
|
|
42
|
+
.slice(0, 32);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function remainingTtlMs(seenAt: number, now: number): number {
|
|
46
|
+
return Math.max(1, DEDUP_TTL_MS - (now - seenAt));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildMigrationEntries(namespace: string, sourcePath: string, now: number) {
|
|
50
|
+
return Object.entries(readLegacyDedupeData(sourcePath)).flatMap(([messageId, seenAt]) => {
|
|
51
|
+
if (now - seenAt >= DEDUP_TTL_MS) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
key: dedupeStoreKey(namespace, messageId),
|
|
57
|
+
value: { namespace, messageId, seenAt },
|
|
58
|
+
ttlMs: remainingTtlMs(seenAt, now),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const detectFeishuLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({
|
|
65
|
+
stateDir,
|
|
66
|
+
}) => {
|
|
67
|
+
const dedupDir = path.join(stateDir, "feishu", "dedup");
|
|
68
|
+
let entries: fs.Dirent[];
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(dedupDir, { withFileTypes: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
return entries.flatMap((entry) => {
|
|
76
|
+
if (!entry.isFile()) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const namespace = safeNamespaceFromFileName(entry.name);
|
|
80
|
+
if (!namespace) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const sourcePath = path.join(dedupDir, entry.name);
|
|
84
|
+
const migrationEntries = buildMigrationEntries(namespace, sourcePath, now);
|
|
85
|
+
if (migrationEntries.length === 0) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
kind: "plugin-state-import" as const,
|
|
91
|
+
label: `Feishu ${namespace} dedupe`,
|
|
92
|
+
sourcePath,
|
|
93
|
+
targetPath: `plugin state:dedup.${namespace}`,
|
|
94
|
+
pluginId: "feishu",
|
|
95
|
+
namespace: `dedup.${namespace}`,
|
|
96
|
+
maxEntries: STORE_MAX_ENTRIES,
|
|
97
|
+
scopeKey: "",
|
|
98
|
+
cleanupSource: "rename" as const,
|
|
99
|
+
readEntries: () => buildMigrationEntries(namespace, sourcePath, now),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
});
|
|
103
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Feishu tests cover dedup plugin behavior.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { OpenKeyedStoreOptions } from "actagent/plugin-sdk/plugin-state-runtime";
|
|
6
|
+
import {
|
|
7
|
+
createPluginStateSyncKeyedStoreForTests,
|
|
8
|
+
resetPluginStateStoreForTests,
|
|
9
|
+
} from "actagent/plugin-sdk/plugin-state-test-runtime";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type { PluginRuntime } from "../runtime-api.js";
|
|
12
|
+
import {
|
|
13
|
+
hasProcessedFeishuMessage,
|
|
14
|
+
testingHooks,
|
|
15
|
+
tryRecordMessagePersistent,
|
|
16
|
+
warmupDedupFromPluginState,
|
|
17
|
+
} from "./dedup.js";
|
|
18
|
+
import { setFeishuRuntime } from "./runtime.js";
|
|
19
|
+
|
|
20
|
+
let tempDir: string | undefined;
|
|
21
|
+
let previousStateDir: string | undefined;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
previousStateDir = process.env.ACTAGENT_STATE_DIR;
|
|
25
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "actagent-feishu-dedup-"));
|
|
26
|
+
process.env.ACTAGENT_STATE_DIR = tempDir;
|
|
27
|
+
setFeishuRuntime({
|
|
28
|
+
state: {
|
|
29
|
+
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
|
|
30
|
+
createPluginStateSyncKeyedStoreForTests("feishu", options),
|
|
31
|
+
},
|
|
32
|
+
} as unknown as PluginRuntime);
|
|
33
|
+
testingHooks.resetFeishuDedupForTests();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
vi.useRealTimers();
|
|
38
|
+
testingHooks.resetFeishuDedupForTests();
|
|
39
|
+
resetPluginStateStoreForTests();
|
|
40
|
+
if (previousStateDir === undefined) {
|
|
41
|
+
delete process.env.ACTAGENT_STATE_DIR;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.ACTAGENT_STATE_DIR = previousStateDir;
|
|
44
|
+
}
|
|
45
|
+
if (tempDir) {
|
|
46
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
tempDir = undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Feishu persistent dedupe", () => {
|
|
52
|
+
it("records message ids in plugin state", async () => {
|
|
53
|
+
await expect(tryRecordMessagePersistent("msg-1", "account-a")).resolves.toBe(true);
|
|
54
|
+
await expect(tryRecordMessagePersistent("msg-1", "account-a")).resolves.toBe(false);
|
|
55
|
+
await expect(hasProcessedFeishuMessage("msg-1", "account-a")).resolves.toBe(true);
|
|
56
|
+
await expect(hasProcessedFeishuMessage("msg-1", "account-b")).resolves.toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("warms memory from persisted plugin state", async () => {
|
|
60
|
+
await expect(tryRecordMessagePersistent("msg-2", "account-a")).resolves.toBe(true);
|
|
61
|
+
testingHooks.resetFeishuDedupMemoryForTests();
|
|
62
|
+
|
|
63
|
+
await expect(warmupDedupFromPluginState("account-a")).resolves.toBe(1);
|
|
64
|
+
await expect(tryRecordMessagePersistent("msg-2", "account-a")).resolves.toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("ignores expired persisted entries", async () => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
vi.setSystemTime(1_000);
|
|
70
|
+
await expect(tryRecordMessagePersistent("msg-3", "account-a")).resolves.toBe(true);
|
|
71
|
+
testingHooks.resetFeishuDedupMemoryForTests();
|
|
72
|
+
|
|
73
|
+
vi.setSystemTime(1_000 + 24 * 60 * 60 * 1000 + 1);
|
|
74
|
+
await expect(hasProcessedFeishuMessage("msg-3", "account-a")).resolves.toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ignores legacy JSON dedupe files at runtime", async () => {
|
|
78
|
+
vi.useFakeTimers();
|
|
79
|
+
vi.setSystemTime(2_000);
|
|
80
|
+
const legacyPath = path.join(tempDir as string, "feishu", "dedup", "account-a.json");
|
|
81
|
+
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
|
82
|
+
await fs.writeFile(
|
|
83
|
+
legacyPath,
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
"msg-legacy": 1_000,
|
|
86
|
+
"msg-expired": 2_000 - 24 * 60 * 60 * 1000 - 1,
|
|
87
|
+
}),
|
|
88
|
+
"utf8",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await expect(hasProcessedFeishuMessage("msg-legacy", "account-a")).resolves.toBe(false);
|
|
92
|
+
await expect(tryRecordMessagePersistent("msg-legacy", "account-a")).resolves.toBe(true);
|
|
93
|
+
await expect(hasProcessedFeishuMessage("msg-expired", "account-a")).resolves.toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|