koishi-plugin-dify-ai 0.1.3 → 0.1.5
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/lib/config.d.ts +60 -5
- package/lib/history.d.ts +1 -1
- package/lib/index.js +453 -112
- package/lib/requester/files.d.ts +12 -2
- package/lib/requester/render.d.ts +1 -0
- package/lib/trigger/conditions.d.ts +1 -1
- package/lib/trigger/cooldown.d.ts +46 -0
- package/lib/trigger/engine.d.ts +15 -6
- package/lib/trigger/pool.d.ts +43 -0
- package/lib/types.d.ts +19 -3
- package/lib/utils/session-text.d.ts +6 -2
- package/package.json +1 -1
- package/readme.md +1 -1
- package/lib/trigger/density.d.ts +0 -15
- package/lib/trigger/digest.d.ts +0 -26
package/lib/config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Schema } from 'koishi';
|
|
2
|
-
import { DifyRule } from './types';
|
|
2
|
+
import { CooldownSetting, DifyRule } from './types';
|
|
3
3
|
export type AppTypeSetting = 'auto' | 'chat' | 'agent-chat' | 'advanced-chat' | 'workflow' | 'completion';
|
|
4
4
|
export interface AppConfigEntry {
|
|
5
5
|
name: string;
|
|
@@ -35,16 +35,23 @@ export type ConditionEntry = {
|
|
|
35
35
|
densityCount?: number;
|
|
36
36
|
densityWindowSeconds?: number;
|
|
37
37
|
};
|
|
38
|
-
/**
|
|
38
|
+
/** 变量映射的条件分支形态:先选来源分支,再填该分支的字段(历史消息是独立分支) */
|
|
39
39
|
export type VariableEntry = {
|
|
40
40
|
source: 'session';
|
|
41
41
|
field: string;
|
|
42
42
|
key: string;
|
|
43
|
+
} | {
|
|
44
|
+
source: 'history';
|
|
45
|
+
field: string;
|
|
43
46
|
limit?: number;
|
|
44
47
|
} | {
|
|
45
48
|
source: 'meta';
|
|
46
49
|
field: string;
|
|
47
50
|
key: string;
|
|
51
|
+
} | {
|
|
52
|
+
source: 'constant';
|
|
53
|
+
field: string;
|
|
54
|
+
value: string;
|
|
48
55
|
};
|
|
49
56
|
/** 作用域的一行:维度 + 值 */
|
|
50
57
|
export interface ScopeRow {
|
|
@@ -61,23 +68,71 @@ export interface RuleEntry {
|
|
|
61
68
|
scope: ScopeRow[];
|
|
62
69
|
variables: VariableEntry[];
|
|
63
70
|
}
|
|
71
|
+
/** 引用触发消息的条件分支形态:关闭时空分支,开启时按耗时阈值决定是否引用 */
|
|
72
|
+
export type QuoteEntry = {
|
|
73
|
+
mode: 'off';
|
|
74
|
+
} | {
|
|
75
|
+
mode: 'on';
|
|
76
|
+
minElapsedSeconds: number;
|
|
77
|
+
};
|
|
78
|
+
/** 触发后冷却的条件分支形态 */
|
|
79
|
+
export type CooldownEntry = {
|
|
80
|
+
mode: 'off';
|
|
81
|
+
} | {
|
|
82
|
+
mode: 'fixed';
|
|
83
|
+
seconds?: number;
|
|
84
|
+
} | {
|
|
85
|
+
mode: 'sliding';
|
|
86
|
+
minSeconds?: number;
|
|
87
|
+
maxSeconds?: number;
|
|
88
|
+
};
|
|
89
|
+
/** 上传消息文件的条件分支形态:关闭时空分支,开启时限制数量与单文件大小 */
|
|
90
|
+
export type UploadEntry = {
|
|
91
|
+
mode: 'off';
|
|
92
|
+
} | {
|
|
93
|
+
mode: 'on';
|
|
94
|
+
maxCount?: number;
|
|
95
|
+
maxSizeMb?: number;
|
|
96
|
+
};
|
|
64
97
|
export interface Config {
|
|
65
98
|
apps: AppConfigEntry[];
|
|
66
99
|
rules: RuleEntry[];
|
|
67
100
|
parseElements: boolean;
|
|
68
|
-
|
|
101
|
+
quote: QuoteEntry;
|
|
69
102
|
maxSegmentLength: number;
|
|
70
|
-
|
|
103
|
+
/** boolean 是 0.1.x 的遗留形态,仅迁移读取(true → 开启不限,false → 关闭) */
|
|
104
|
+
uploadFiles: UploadEntry | boolean;
|
|
105
|
+
/** 0.1.x 的布尔开关,仅用于迁移读取(true → 总是引用,false → 关闭) */
|
|
106
|
+
quoteTrigger?: boolean;
|
|
71
107
|
requestTimeout: number;
|
|
72
108
|
historyLimit: number;
|
|
73
109
|
sendErrors: boolean;
|
|
110
|
+
cooldown: CooldownEntry;
|
|
74
111
|
}
|
|
75
112
|
export declare const Config: Schema<Config>;
|
|
76
113
|
/**
|
|
77
|
-
*
|
|
114
|
+
* 上传消息文件配置(含 0.1.x 布尔遗留形态迁移):
|
|
115
|
+
* 数量与单文件大小 0 均为不限,大小仍与 Dify 各类型上限取较小值。
|
|
116
|
+
*/
|
|
117
|
+
export declare function resolveUploadConfig(config: Config): {
|
|
118
|
+
enabled: boolean;
|
|
119
|
+
maxCount: number;
|
|
120
|
+
maxMb: number;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* 引用触发消息配置(含 0.1.x 布尔开关迁移):schema 形态缺失时
|
|
124
|
+
* 旧 quoteTrigger 若被配置层透传保留则迁移,否则按总是引用处理。
|
|
125
|
+
*/
|
|
126
|
+
export declare function resolveQuoteConfig(config: Config): {
|
|
127
|
+
enabled: boolean;
|
|
128
|
+
minElapsedSeconds: number;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* 配置规范化:应用表 + 按优先级降序的规则列表 + 冷却设置。
|
|
78
132
|
* 坏条件/无名变量直接丢弃;引用不存在应用的规则保留但运行时会 warn 跳过。
|
|
79
133
|
*/
|
|
80
134
|
export declare function normalizeConfig(config: Config): {
|
|
81
135
|
apps: Map<string, AppConfigEntry>;
|
|
82
136
|
rules: DifyRule[];
|
|
137
|
+
cooldown: CooldownSetting | null;
|
|
83
138
|
};
|
package/lib/history.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface HistoryEntry {
|
|
|
24
24
|
export declare function formatHistoryEntry(entry: HistoryEntry): string;
|
|
25
25
|
/** 多条消息逐行拼接 */
|
|
26
26
|
export declare function formatHistoryEntries(entries: HistoryEntry[]): string;
|
|
27
|
-
/** 从会话消息构造记录条目(历史缓冲 /
|
|
27
|
+
/** 从会话消息构造记录条目(历史缓冲 / 消息池共用);assistant 行标注为机器人自己的发言 */
|
|
28
28
|
export declare function buildHistoryEntry(session: Session, role?: 'user' | 'assistant'): HistoryEntry | null;
|
|
29
29
|
/**
|
|
30
30
|
* 每个频道在内存中保留最近消息(含 bot 自己的回复),
|
package/lib/index.js
CHANGED
|
@@ -92,6 +92,7 @@ var conditionSchema = import_koishi.Schema.union([
|
|
|
92
92
|
}).default({ type: "density", densityCount: 5, densityWindowSeconds: 60 }).description("消息密度")
|
|
93
93
|
]);
|
|
94
94
|
var sessionKeySchema = import_koishi.Schema.union([
|
|
95
|
+
import_koishi.Schema.const("none").description("无"),
|
|
95
96
|
import_koishi.Schema.const("userId").description("用户 ID"),
|
|
96
97
|
import_koishi.Schema.const("botId").description("Bot ID"),
|
|
97
98
|
import_koishi.Schema.const("guildId").description("群聊 ID"),
|
|
@@ -103,10 +104,10 @@ var sessionKeySchema = import_koishi.Schema.union([
|
|
|
103
104
|
import_koishi.Schema.const("quoteAuthorId").description("被引用作者"),
|
|
104
105
|
import_koishi.Schema.const("username").description("用户名"),
|
|
105
106
|
import_koishi.Schema.const("timestamp").description("触发时间"),
|
|
106
|
-
import_koishi.Schema.const("isDirect").description("是否私聊")
|
|
107
|
-
import_koishi.Schema.const("history").description("历史消息")
|
|
107
|
+
import_koishi.Schema.const("isDirect").description("是否私聊")
|
|
108
108
|
]);
|
|
109
109
|
var metaKeySchema = import_koishi.Schema.union([
|
|
110
|
+
import_koishi.Schema.const("none").description("无"),
|
|
110
111
|
import_koishi.Schema.const("triggerType").description("触发类型"),
|
|
111
112
|
import_koishi.Schema.const("ruleName").description("规则名"),
|
|
112
113
|
import_koishi.Schema.const("triggerTime").description("触发时间")
|
|
@@ -115,14 +116,56 @@ var variableSchema = import_koishi.Schema.union([
|
|
|
115
116
|
import_koishi.Schema.object({
|
|
116
117
|
source: import_koishi.Schema.const("session").required(),
|
|
117
118
|
field: import_koishi.Schema.string().required().description("字段名"),
|
|
118
|
-
key: sessionKeySchema.default("
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
key: sessionKeySchema.default("none").description("变量")
|
|
120
|
+
}).default({ source: "session", field: "", key: "none" }).description("会话字段"),
|
|
121
|
+
import_koishi.Schema.object({
|
|
122
|
+
source: import_koishi.Schema.const("history").required(),
|
|
123
|
+
field: import_koishi.Schema.string().required().description("字段名"),
|
|
124
|
+
limit: import_koishi.Schema.natural().default(0).min(0).max(100).description("条数,0 用全局")
|
|
125
|
+
}).default({ source: "history", field: "", limit: 0 }).description("历史消息"),
|
|
121
126
|
import_koishi.Schema.object({
|
|
122
127
|
source: import_koishi.Schema.const("meta").required(),
|
|
123
128
|
field: import_koishi.Schema.string().required().description("字段名"),
|
|
124
|
-
key: metaKeySchema.default("
|
|
125
|
-
}).default({ source: "meta", field: "", key: "
|
|
129
|
+
key: metaKeySchema.default("none").description("变量")
|
|
130
|
+
}).default({ source: "meta", field: "", key: "none" }).description("触发元信息"),
|
|
131
|
+
import_koishi.Schema.object({
|
|
132
|
+
source: import_koishi.Schema.const("constant").required(),
|
|
133
|
+
field: import_koishi.Schema.string().required().description("字段名"),
|
|
134
|
+
value: import_koishi.Schema.string().role("textarea").required().description("固定文本")
|
|
135
|
+
}).default({ source: "constant", field: "", value: "" }).description("常量文本")
|
|
136
|
+
]);
|
|
137
|
+
var quoteSchema = import_koishi.Schema.union([
|
|
138
|
+
import_koishi.Schema.object({
|
|
139
|
+
mode: import_koishi.Schema.const("off").required()
|
|
140
|
+
}).default({ mode: "off" }).description("关闭"),
|
|
141
|
+
import_koishi.Schema.object({
|
|
142
|
+
mode: import_koishi.Schema.const("on").required(),
|
|
143
|
+
minElapsedSeconds: import_koishi.Schema.natural().default(0).description("回复耗时多少秒才引用,0 总是引用")
|
|
144
|
+
}).default({ mode: "on", minElapsedSeconds: 0 }).description("开启")
|
|
145
|
+
]);
|
|
146
|
+
var cooldownSchema = import_koishi.Schema.union([
|
|
147
|
+
import_koishi.Schema.object({
|
|
148
|
+
mode: import_koishi.Schema.const("off").required()
|
|
149
|
+
}).default({ mode: "off" }).description("关闭"),
|
|
150
|
+
import_koishi.Schema.object({
|
|
151
|
+
mode: import_koishi.Schema.const("fixed").required(),
|
|
152
|
+
seconds: import_koishi.Schema.natural().default(30).min(1).description("冷却秒数")
|
|
153
|
+
}).default({ mode: "fixed", seconds: 30 }).description("固定窗口"),
|
|
154
|
+
import_koishi.Schema.object({
|
|
155
|
+
mode: import_koishi.Schema.const("sliding").required(),
|
|
156
|
+
minSeconds: import_koishi.Schema.natural().default(30).min(1).description("最小冷却秒数"),
|
|
157
|
+
maxSeconds: import_koishi.Schema.natural().default(120).min(1).description("最大冷却秒数")
|
|
158
|
+
}).default({ mode: "sliding", minSeconds: 30, maxSeconds: 120 }).description("滑动窗口")
|
|
159
|
+
]);
|
|
160
|
+
var uploadSchema = import_koishi.Schema.union([
|
|
161
|
+
import_koishi.Schema.object({
|
|
162
|
+
mode: import_koishi.Schema.const("off").required()
|
|
163
|
+
}).default({ mode: "off" }).description("关闭"),
|
|
164
|
+
import_koishi.Schema.object({
|
|
165
|
+
mode: import_koishi.Schema.const("on").required(),
|
|
166
|
+
maxCount: import_koishi.Schema.natural().default(0).description("数量上限,0 不限"),
|
|
167
|
+
maxSizeMb: import_koishi.Schema.natural().default(0).description("单文件大小上限 MB,0 不限")
|
|
168
|
+
}).default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("开启")
|
|
126
169
|
]);
|
|
127
170
|
var Config = import_koishi.Schema.intersect([
|
|
128
171
|
import_koishi.Schema.object({
|
|
@@ -170,14 +213,15 @@ var Config = import_koishi.Schema.intersect([
|
|
|
170
213
|
}).description("触发规则"),
|
|
171
214
|
import_koishi.Schema.object({
|
|
172
215
|
parseElements: import_koishi.Schema.boolean().default(false).description("解析回复中的消息元素"),
|
|
173
|
-
|
|
216
|
+
quote: quoteSchema.default({ mode: "on", minElapsedSeconds: 0 }).description("引用触发消息"),
|
|
174
217
|
maxSegmentLength: import_koishi.Schema.natural().default(0).description("分段长度,0 不分段"),
|
|
175
|
-
uploadFiles:
|
|
218
|
+
uploadFiles: uploadSchema.default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("上传消息中的文件")
|
|
176
219
|
}).description("回复行为"),
|
|
177
220
|
import_koishi.Schema.object({
|
|
178
221
|
requestTimeout: import_koishi.Schema.natural().role("ms").default(3e5).description("请求超时"),
|
|
179
222
|
historyLimit: import_koishi.Schema.natural().default(20).min(1).description("历史消息条数"),
|
|
180
|
-
sendErrors: import_koishi.Schema.boolean().default(false).description("失败时提示错误")
|
|
223
|
+
sendErrors: import_koishi.Schema.boolean().default(false).description("失败时提示错误"),
|
|
224
|
+
cooldown: cooldownSchema.default({ mode: "off" }).description("触发后冷却")
|
|
181
225
|
}).description("运行参数")
|
|
182
226
|
]);
|
|
183
227
|
var CONVERSATION_SCOPES = ["per-user", "per-guild", "per-channel", "global"];
|
|
@@ -266,16 +310,61 @@ __name(toScope, "toScope");
|
|
|
266
310
|
function toVariable(entry) {
|
|
267
311
|
const field = entry.field?.trim();
|
|
268
312
|
if (!field) return null;
|
|
313
|
+
if (entry.source === "constant") {
|
|
314
|
+
if (!entry.value) return null;
|
|
315
|
+
return { field, source: { type: "constant", value: entry.value } };
|
|
316
|
+
}
|
|
269
317
|
if (entry.source === "meta") {
|
|
318
|
+
if (entry.key === "none") return null;
|
|
270
319
|
return { field, source: { type: "meta", key: entry.key || "triggerType" } };
|
|
271
320
|
}
|
|
272
|
-
if (entry.
|
|
321
|
+
if (entry.source === "history") {
|
|
273
322
|
const limit = Number(entry.limit);
|
|
274
323
|
return { field, source: { type: "history", ...limit > 0 ? { limit } : {} } };
|
|
275
324
|
}
|
|
325
|
+
if (entry.key === "none" || entry.key === "history") return null;
|
|
276
326
|
return { field, source: { type: "session", key: entry.key || "content" } };
|
|
277
327
|
}
|
|
278
328
|
__name(toVariable, "toVariable");
|
|
329
|
+
function resolveUploadConfig(config) {
|
|
330
|
+
if (typeof config.uploadFiles === "boolean") {
|
|
331
|
+
return config.uploadFiles ? { enabled: true, maxCount: 0, maxMb: 0 } : { enabled: false, maxCount: 0, maxMb: 0 };
|
|
332
|
+
}
|
|
333
|
+
if (config.uploadFiles?.mode === "off") return { enabled: false, maxCount: 0, maxMb: 0 };
|
|
334
|
+
return {
|
|
335
|
+
enabled: true,
|
|
336
|
+
maxCount: Math.max(0, Number(config.uploadFiles?.maxCount) || 0),
|
|
337
|
+
maxMb: Math.max(0, Number(config.uploadFiles?.maxSizeMb) || 0)
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
__name(resolveUploadConfig, "resolveUploadConfig");
|
|
341
|
+
function resolveQuoteConfig(config) {
|
|
342
|
+
if (config.quote?.mode === "off") return { enabled: false, minElapsedSeconds: 0 };
|
|
343
|
+
if (config.quote?.mode === "on") {
|
|
344
|
+
return { enabled: true, minElapsedSeconds: Math.max(0, Number(config.quote.minElapsedSeconds) || 0) };
|
|
345
|
+
}
|
|
346
|
+
if (config.quoteTrigger === false) return { enabled: false, minElapsedSeconds: 0 };
|
|
347
|
+
return { enabled: true, minElapsedSeconds: 0 };
|
|
348
|
+
}
|
|
349
|
+
__name(resolveQuoteConfig, "resolveQuoteConfig");
|
|
350
|
+
function toCooldown(entry) {
|
|
351
|
+
if (entry?.mode === "fixed") {
|
|
352
|
+
const seconds = Number(entry.seconds);
|
|
353
|
+
return seconds > 0 ? { mode: "fixed", ms: seconds * 1e3 } : null;
|
|
354
|
+
}
|
|
355
|
+
if (entry?.mode === "sliding") {
|
|
356
|
+
const minSeconds = Number(entry.minSeconds);
|
|
357
|
+
const maxSeconds = Number(entry.maxSeconds);
|
|
358
|
+
if (!(minSeconds > 0) || !(maxSeconds > 0)) return null;
|
|
359
|
+
return {
|
|
360
|
+
mode: "sliding",
|
|
361
|
+
minMs: minSeconds * 1e3,
|
|
362
|
+
maxMs: Math.max(minSeconds, maxSeconds) * 1e3
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
__name(toCooldown, "toCooldown");
|
|
279
368
|
function normalizeConfig(config) {
|
|
280
369
|
const apps = /* @__PURE__ */ new Map();
|
|
281
370
|
for (const app of config.apps ?? []) {
|
|
@@ -298,7 +387,7 @@ function normalizeConfig(config) {
|
|
|
298
387
|
});
|
|
299
388
|
}
|
|
300
389
|
rules.sort((a, b) => b.priority - a.priority);
|
|
301
|
-
return { apps, rules };
|
|
390
|
+
return { apps, rules, cooldown: toCooldown(config.cooldown) };
|
|
302
391
|
}
|
|
303
392
|
__name(normalizeConfig, "normalizeConfig");
|
|
304
393
|
|
|
@@ -409,6 +498,7 @@ function createDifyClient(ctx, app, timeout) {
|
|
|
409
498
|
__name(createDifyClient, "createDifyClient");
|
|
410
499
|
|
|
411
500
|
// src/utils/session-text.ts
|
|
501
|
+
var import_crypto = require("crypto");
|
|
412
502
|
var import_koishi2 = require("koishi");
|
|
413
503
|
function parseTextSafe(content) {
|
|
414
504
|
if (!content) return "";
|
|
@@ -428,6 +518,20 @@ function extractText(session, options = {}) {
|
|
|
428
518
|
}
|
|
429
519
|
__name(extractText, "extractText");
|
|
430
520
|
var IMAGE_PLACEHOLDER = "[图片]";
|
|
521
|
+
function imagePlaceholder(url) {
|
|
522
|
+
if (!url) return IMAGE_PLACEHOLDER;
|
|
523
|
+
return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0, 8)}]`;
|
|
524
|
+
}
|
|
525
|
+
__name(imagePlaceholder, "imagePlaceholder");
|
|
526
|
+
function annotateDroppedPlaceholders(text, droppedUrls) {
|
|
527
|
+
let result = text;
|
|
528
|
+
for (const url of droppedUrls) {
|
|
529
|
+
const placeholder = imagePlaceholder(url);
|
|
530
|
+
result = result.split(placeholder).join(`${placeholder}(已丢弃)`);
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
__name(annotateDroppedPlaceholders, "annotateDroppedPlaceholders");
|
|
431
535
|
function extractTextWithPlaceholders(session) {
|
|
432
536
|
const elements = session.elements?.length ? session.elements : (() => {
|
|
433
537
|
try {
|
|
@@ -442,7 +546,7 @@ function extractTextWithPlaceholders(session) {
|
|
|
442
546
|
const text = element.attrs?.["content"] ?? "";
|
|
443
547
|
if (text) parts.push(text);
|
|
444
548
|
} else if (element.type === "img" || element.type === "image") {
|
|
445
|
-
parts.push(
|
|
549
|
+
parts.push(imagePlaceholder(element.attrs?.["url"] ?? element.attrs?.["src"]));
|
|
446
550
|
}
|
|
447
551
|
}
|
|
448
552
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
@@ -459,9 +563,9 @@ function formatTimestamp(timestamp) {
|
|
|
459
563
|
}
|
|
460
564
|
__name(formatTimestamp, "formatTimestamp");
|
|
461
565
|
function formatHistoryEntry(entry) {
|
|
462
|
-
const quote = entry.quote ? `(引用了 ${formatTimestamp(entry.quote.timestamp)} ${entry.quote.username} (${entry.quote.userId}) 说:${entry.quote.content})` : "";
|
|
566
|
+
const quote = entry.quote ? `(引用了 [${formatTimestamp(entry.quote.timestamp)}] ${entry.quote.username} (${entry.quote.userId}) 说:${entry.quote.content})` : "";
|
|
463
567
|
const messageId = entry.messageId ? ` [msg:${entry.messageId}]` : "";
|
|
464
|
-
return
|
|
568
|
+
return `[${formatTimestamp(entry.timestamp)}] ${entry.username} (${entry.userId})${messageId}${quote}${quote ? "" : " "}说:${entry.content}`;
|
|
465
569
|
}
|
|
466
570
|
__name(formatHistoryEntry, "formatHistoryEntry");
|
|
467
571
|
function formatHistoryEntries(entries) {
|
|
@@ -485,7 +589,7 @@ function buildHistoryEntry(session, role = "user") {
|
|
|
485
589
|
return {
|
|
486
590
|
role,
|
|
487
591
|
userId: role === "assistant" ? session.selfId : session.userId ?? "未知用户",
|
|
488
|
-
username: role === "assistant" ? "
|
|
592
|
+
username: role === "assistant" ? "你自己的发言" : session.username || session.userId || "用户",
|
|
489
593
|
messageId: session.messageId,
|
|
490
594
|
content,
|
|
491
595
|
timestamp: session.timestamp ?? Date.now(),
|
|
@@ -592,73 +696,95 @@ function guildKeyOf(platform, guildId, channelId) {
|
|
|
592
696
|
}
|
|
593
697
|
__name(guildKeyOf, "guildKeyOf");
|
|
594
698
|
|
|
595
|
-
// src/trigger/
|
|
596
|
-
var
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
699
|
+
// src/trigger/pool.ts
|
|
700
|
+
var POOL_CAPACITY = 1024;
|
|
701
|
+
function scopeSessionOf(pool, entry) {
|
|
702
|
+
return {
|
|
703
|
+
platform: pool.platform,
|
|
704
|
+
selfId: pool.selfId,
|
|
705
|
+
channelId: pool.channelId,
|
|
706
|
+
guildId: pool.guildId,
|
|
707
|
+
userId: entry.userId,
|
|
708
|
+
isDirect: pool.isDirect
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
__name(scopeSessionOf, "scopeSessionOf");
|
|
712
|
+
var MessagePool = class {
|
|
600
713
|
static {
|
|
601
|
-
__name(this, "
|
|
714
|
+
__name(this, "MessagePool");
|
|
715
|
+
}
|
|
716
|
+
channels = /* @__PURE__ */ new Map();
|
|
717
|
+
/** 一条消息(含 bot 自己的发言)入池,全量记录不做规则过滤 */
|
|
718
|
+
record(session, entry) {
|
|
719
|
+
const key = channelKeyOf(session.platform, session.channelId);
|
|
720
|
+
let pool = this.channels.get(key);
|
|
721
|
+
if (!pool) {
|
|
722
|
+
pool = {
|
|
723
|
+
platform: session.platform,
|
|
724
|
+
channelId: session.channelId,
|
|
725
|
+
guildId: session.guildId,
|
|
726
|
+
selfId: session.selfId,
|
|
727
|
+
isDirect: session.isDirect,
|
|
728
|
+
entries: []
|
|
729
|
+
};
|
|
730
|
+
this.channels.set(key, pool);
|
|
731
|
+
}
|
|
732
|
+
pool.selfId = session.selfId;
|
|
733
|
+
pool.guildId = session.guildId;
|
|
734
|
+
pool.isDirect = session.isDirect;
|
|
735
|
+
pool.entries.push(entry);
|
|
736
|
+
if (pool.entries.length > POOL_CAPACITY) {
|
|
737
|
+
pool.entries.splice(0, pool.entries.length - POOL_CAPACITY);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** 时效内且通过规则作用域的真人条数(bot 行不推高密度,防自激励) */
|
|
741
|
+
countQualified(pool, rule, condition, now) {
|
|
742
|
+
const windowMs = condition.densityWindowSeconds * 1e3;
|
|
743
|
+
return pool.entries.filter(
|
|
744
|
+
(item) => item.role === "user" && now - item.timestamp <= windowMs && matchScope(rule.scope, scopeSessionOf(pool, item))
|
|
745
|
+
).length;
|
|
602
746
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
747
|
+
/**
|
|
748
|
+
* 新消息入池后的密度评估:时效内(相对触发消息)且通过规则作用域的真人条数达到阈值。
|
|
749
|
+
* 触发即取走并清空该频道全池(bot 行不推高密度,防自激励);池清空后其余规则无从触发。
|
|
750
|
+
* skip 返回 true 的条件(冷却中的密度条件)不参与判定。
|
|
751
|
+
*/
|
|
752
|
+
checkDensity(rules, session, skip) {
|
|
753
|
+
const key = channelKeyOf(session.platform, session.channelId);
|
|
754
|
+
const pool = this.channels.get(key);
|
|
755
|
+
if (!pool) return [];
|
|
756
|
+
const now = session.timestamp ?? Date.now();
|
|
606
757
|
for (const rule of rules) {
|
|
607
|
-
for (
|
|
758
|
+
for (let index = 0; index < rule.conditions.length; index++) {
|
|
759
|
+
const condition = rule.conditions[index];
|
|
608
760
|
if (condition.type !== "density") continue;
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
if (entries.length >= condition.densityCount) {
|
|
614
|
-
this.windows.delete(key);
|
|
615
|
-
this.onTrigger(rule, session, entries);
|
|
616
|
-
} else {
|
|
617
|
-
this.windows.set(key, entries);
|
|
761
|
+
if (skip?.(rule, condition, index)) continue;
|
|
762
|
+
if (this.countQualified(pool, rule, condition, now) >= condition.densityCount) {
|
|
763
|
+
this.channels.delete(key);
|
|
764
|
+
return [{ rule, condition, conditionIndex: index, entries: pool.entries }];
|
|
618
765
|
}
|
|
619
766
|
}
|
|
620
767
|
}
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
channels = /* @__PURE__ */ new Map();
|
|
640
|
-
this.windows.set(rule.name, channels);
|
|
641
|
-
}
|
|
642
|
-
let digest = channels.get(key);
|
|
643
|
-
if (!digest) {
|
|
644
|
-
digest = {
|
|
645
|
-
platform: session.platform,
|
|
646
|
-
channelId: session.channelId,
|
|
647
|
-
guildId: session.guildId,
|
|
648
|
-
selfId: session.selfId,
|
|
649
|
-
entries: []
|
|
650
|
-
};
|
|
651
|
-
channels.set(key, digest);
|
|
652
|
-
}
|
|
653
|
-
digest.entries.push(entry);
|
|
768
|
+
return [];
|
|
769
|
+
}
|
|
770
|
+
/** 冷却到期复查:按条件参数统计该频道池(now 为复查时刻),达标即取走整池,未达标返回 null */
|
|
771
|
+
takeDensityChannel(rule, condition, channelKey, now) {
|
|
772
|
+
const pool = this.channels.get(channelKey);
|
|
773
|
+
if (!pool) return null;
|
|
774
|
+
if (this.countQualified(pool, rule, condition, now) < condition.densityCount) return null;
|
|
775
|
+
this.channels.delete(channelKey);
|
|
776
|
+
return pool;
|
|
777
|
+
}
|
|
778
|
+
/** 定时到点:取走该规则作用域内有消息的所有频道(整池清空,含未过作用域的条目) */
|
|
779
|
+
takeSchedule(rule) {
|
|
780
|
+
const taken = [];
|
|
781
|
+
for (const [key, pool] of this.channels) {
|
|
782
|
+
const matched = pool.entries.some((entry) => matchScope(rule.scope, scopeSessionOf(pool, entry)));
|
|
783
|
+
if (!matched) continue;
|
|
784
|
+
taken.push(pool);
|
|
785
|
+
this.channels.delete(key);
|
|
654
786
|
}
|
|
655
|
-
|
|
656
|
-
/** 取走规则窗口内的全部非空频道缓冲(并清空该规则的窗口) */
|
|
657
|
-
take(rule) {
|
|
658
|
-
const channels = this.windows.get(rule.name);
|
|
659
|
-
this.windows.delete(rule.name);
|
|
660
|
-
if (!channels) return [];
|
|
661
|
-
return [...channels.values()].filter((digest) => digest.entries.length > 0);
|
|
787
|
+
return taken;
|
|
662
788
|
}
|
|
663
789
|
};
|
|
664
790
|
|
|
@@ -729,30 +855,134 @@ var RuleScheduler = class {
|
|
|
729
855
|
}
|
|
730
856
|
};
|
|
731
857
|
|
|
858
|
+
// src/trigger/cooldown.ts
|
|
859
|
+
var BUFFER_CAPACITY = 1024;
|
|
860
|
+
function densityLane(index) {
|
|
861
|
+
return `density:${index}`;
|
|
862
|
+
}
|
|
863
|
+
__name(densityLane, "densityLane");
|
|
864
|
+
function laneKeyOf(ruleName, lane) {
|
|
865
|
+
return `${ruleName}
|
|
866
|
+
${lane}`;
|
|
867
|
+
}
|
|
868
|
+
__name(laneKeyOf, "laneKeyOf");
|
|
869
|
+
var TriggerCooldown = class {
|
|
870
|
+
constructor(setting) {
|
|
871
|
+
this.setting = setting;
|
|
872
|
+
}
|
|
873
|
+
static {
|
|
874
|
+
__name(this, "TriggerCooldown");
|
|
875
|
+
}
|
|
876
|
+
/** channelKey → (规则名+通道 → 冷却态) */
|
|
877
|
+
channels = /* @__PURE__ */ new Map();
|
|
878
|
+
get enabled() {
|
|
879
|
+
return this.setting != null;
|
|
880
|
+
}
|
|
881
|
+
/** 触发发起时登记冷却 */
|
|
882
|
+
begin(channelKey, ruleName, lane, now) {
|
|
883
|
+
if (!this.setting) return;
|
|
884
|
+
const ms = this.setting.mode === "fixed" ? this.setting.ms : this.setting.minMs;
|
|
885
|
+
this.lanesOf(channelKey).set(laneKeyOf(ruleName, lane), {
|
|
886
|
+
fireAt: now,
|
|
887
|
+
deadline: now + ms,
|
|
888
|
+
buffer: []
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
isCooling(channelKey, ruleName, lane, now) {
|
|
892
|
+
const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
|
|
893
|
+
return state != null && now < state.deadline;
|
|
894
|
+
}
|
|
895
|
+
/** 滑动窗口顺延:截止时间顺延到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
|
|
896
|
+
extend(channelKey, ruleName, lane, now) {
|
|
897
|
+
if (this.setting?.mode !== "sliding") return;
|
|
898
|
+
const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
|
|
899
|
+
if (!state) return;
|
|
900
|
+
state.deadline = Math.min(
|
|
901
|
+
Math.max(state.deadline, now + this.setting.minMs),
|
|
902
|
+
state.fireAt + this.setting.maxMs
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
/** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
|
|
906
|
+
extendDensityLanes(channelKey, now) {
|
|
907
|
+
if (this.setting?.mode !== "sliding") return;
|
|
908
|
+
const lanes = this.channels.get(channelKey);
|
|
909
|
+
if (!lanes) return;
|
|
910
|
+
for (const [key, state] of lanes) {
|
|
911
|
+
if (!key.includes("\ndensity:")) continue;
|
|
912
|
+
state.deadline = Math.min(
|
|
913
|
+
Math.max(state.deadline, now + this.setting.minMs),
|
|
914
|
+
state.fireAt + this.setting.maxMs
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/** 冷却期内缓存一条命中消息(仅消息通道) */
|
|
919
|
+
bufferMessage(channelKey, ruleName, message) {
|
|
920
|
+
const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, "message"));
|
|
921
|
+
if (!state) return;
|
|
922
|
+
state.buffer.push(message);
|
|
923
|
+
if (state.buffer.length > BUFFER_CAPACITY) {
|
|
924
|
+
state.buffer.splice(0, state.buffer.length - BUFFER_CAPACITY);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/** 到期扫描:取走全部到期通道(连缓冲一并交出),由调用方决定是否重新登记 */
|
|
928
|
+
collectExpired(now) {
|
|
929
|
+
const expired = [];
|
|
930
|
+
for (const [channelKey, lanes] of this.channels) {
|
|
931
|
+
for (const [key, state] of lanes) {
|
|
932
|
+
if (now < state.deadline) continue;
|
|
933
|
+
const separator = key.indexOf("\n");
|
|
934
|
+
lanes.delete(key);
|
|
935
|
+
expired.push({
|
|
936
|
+
channelKey,
|
|
937
|
+
ruleName: key.slice(0, separator),
|
|
938
|
+
lane: key.slice(separator + 1),
|
|
939
|
+
buffer: state.buffer
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
if (!lanes.size) this.channels.delete(channelKey);
|
|
943
|
+
}
|
|
944
|
+
return expired;
|
|
945
|
+
}
|
|
946
|
+
lanesOf(channelKey) {
|
|
947
|
+
let lanes = this.channels.get(channelKey);
|
|
948
|
+
if (!lanes) {
|
|
949
|
+
lanes = /* @__PURE__ */ new Map();
|
|
950
|
+
this.channels.set(channelKey, lanes);
|
|
951
|
+
}
|
|
952
|
+
return lanes;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
732
956
|
// src/trigger/engine.ts
|
|
733
957
|
var SCHEDULER_TICK_MS = 1e3;
|
|
734
958
|
var TriggerEngine = class {
|
|
735
|
-
constructor(ctx, runner, history, rules) {
|
|
959
|
+
constructor(ctx, runner, history, rules, cooldown) {
|
|
736
960
|
this.ctx = ctx;
|
|
737
961
|
this.runner = runner;
|
|
738
962
|
this.history = history;
|
|
739
963
|
this.rules = rules;
|
|
740
964
|
this.scheduler = new RuleScheduler(ctx, (rule) => this.fireSchedule(rule));
|
|
741
|
-
this.
|
|
742
|
-
this.
|
|
965
|
+
this.pool = new MessagePool();
|
|
966
|
+
this.cooldown = new TriggerCooldown(cooldown);
|
|
743
967
|
}
|
|
744
968
|
static {
|
|
745
969
|
__name(this, "TriggerEngine");
|
|
746
970
|
}
|
|
747
971
|
scheduler;
|
|
748
|
-
|
|
749
|
-
|
|
972
|
+
pool;
|
|
973
|
+
cooldown;
|
|
750
974
|
start() {
|
|
751
975
|
this.ctx.middleware((session, next) => this.handleMessage(session, next));
|
|
752
|
-
this.ctx.setInterval(() =>
|
|
976
|
+
this.ctx.setInterval(() => {
|
|
977
|
+
this.scheduler.tick();
|
|
978
|
+
this.tickCooldown();
|
|
979
|
+
}, SCHEDULER_TICK_MS);
|
|
753
980
|
this.ctx.on("send", (session) => {
|
|
754
981
|
const entry = session.channelId ? buildHistoryEntry(session, "assistant") : null;
|
|
755
|
-
if (entry)
|
|
982
|
+
if (!entry) return;
|
|
983
|
+
const key = channelKeyOf(session.platform, session.channelId);
|
|
984
|
+
this.history.recordEntry(key, entry);
|
|
985
|
+
this.pool.record(session, entry);
|
|
756
986
|
});
|
|
757
987
|
this.scheduler.sync(this.rules);
|
|
758
988
|
this.ctx.logger.info("已加载 %d 条启用规则", this.rules.length);
|
|
@@ -760,16 +990,34 @@ var TriggerEngine = class {
|
|
|
760
990
|
handleMessage(session, next) {
|
|
761
991
|
if (this.ctx.bots[session.uid]) return next();
|
|
762
992
|
if (!session.channelId) return next();
|
|
993
|
+
const channelKey = channelKeyOf(session.platform, session.channelId);
|
|
763
994
|
const entry = buildHistoryEntry(session);
|
|
764
995
|
if (entry) {
|
|
765
|
-
this.history.recordEntry(
|
|
766
|
-
this.
|
|
767
|
-
|
|
996
|
+
this.history.recordEntry(channelKey, entry);
|
|
997
|
+
this.pool.record(session, entry);
|
|
998
|
+
const now = session.timestamp ?? Date.now();
|
|
999
|
+
this.cooldown.extendDensityLanes(channelKey, now);
|
|
1000
|
+
for (const fire of this.pool.checkDensity(
|
|
1001
|
+
this.rules,
|
|
1002
|
+
session,
|
|
1003
|
+
(rule, _condition, index) => this.cooldown.isCooling(channelKey, rule.name, densityLane(index), now)
|
|
1004
|
+
)) {
|
|
1005
|
+
this.fireDensity(fire, session);
|
|
1006
|
+
}
|
|
768
1007
|
}
|
|
769
1008
|
return next(async (nextMiddleware) => {
|
|
770
1009
|
const matched = this.matchRule(session);
|
|
771
1010
|
if (!matched) return nextMiddleware();
|
|
772
|
-
this.
|
|
1011
|
+
if (this.cooldown.isCooling(channelKey, matched.rule.name, "message", Date.now())) {
|
|
1012
|
+
this.cooldown.bufferMessage(channelKey, matched.rule.name, {
|
|
1013
|
+
session,
|
|
1014
|
+
entry,
|
|
1015
|
+
contentText: matched.condition.queryText
|
|
1016
|
+
});
|
|
1017
|
+
this.cooldown.extend(channelKey, matched.rule.name, "message", Date.now());
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
this.fireMessage(matched.rule, session, matched.condition, entry);
|
|
773
1021
|
});
|
|
774
1022
|
}
|
|
775
1023
|
matchRule(session) {
|
|
@@ -787,7 +1035,12 @@ var TriggerEngine = class {
|
|
|
787
1035
|
run(fireCtx) {
|
|
788
1036
|
void this.runner.run(fireCtx).catch((error) => this.ctx.logger.warn(error));
|
|
789
1037
|
}
|
|
790
|
-
|
|
1038
|
+
/** 登记冷却(定时触发不调用;冷却关闭时零开销直退) */
|
|
1039
|
+
beginCooldown(channelKey, ruleName, lane) {
|
|
1040
|
+
this.cooldown.begin(channelKey, ruleName, lane, Date.now());
|
|
1041
|
+
}
|
|
1042
|
+
fireMessage(rule, session, condition, entry) {
|
|
1043
|
+
this.beginCooldown(channelKeyOf(session.platform, session.channelId), rule.name, "message");
|
|
791
1044
|
this.run({
|
|
792
1045
|
session,
|
|
793
1046
|
platform: session.platform,
|
|
@@ -797,10 +1050,14 @@ var TriggerEngine = class {
|
|
|
797
1050
|
userId: session.userId,
|
|
798
1051
|
triggerType: condition.type,
|
|
799
1052
|
rule,
|
|
800
|
-
|
|
1053
|
+
// query 为该条消息的统一格式单行;纯 @ / 纯引用等无文本消息 entry 为空,由请求器占位兜底
|
|
1054
|
+
queryText: entry ? formatHistoryEntry(entry) : "",
|
|
1055
|
+
contentText: condition.queryText
|
|
801
1056
|
});
|
|
802
1057
|
}
|
|
803
|
-
fireDensity(
|
|
1058
|
+
fireDensity(fire, session) {
|
|
1059
|
+
this.beginCooldown(channelKeyOf(session.platform, session.channelId), fire.rule.name, densityLane(fire.conditionIndex));
|
|
1060
|
+
const windowText = formatHistoryEntries(fire.entries);
|
|
804
1061
|
this.run({
|
|
805
1062
|
session,
|
|
806
1063
|
platform: session.platform,
|
|
@@ -809,16 +1066,52 @@ var TriggerEngine = class {
|
|
|
809
1066
|
guildId: session.guildId ?? session.channelId,
|
|
810
1067
|
userId: session.userId,
|
|
811
1068
|
triggerType: "density",
|
|
1069
|
+
rule: fire.rule,
|
|
1070
|
+
queryText: windowText,
|
|
1071
|
+
contentText: windowText
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
/** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
|
|
1075
|
+
fireMerged(rule, buffer) {
|
|
1076
|
+
const first = buffer[0];
|
|
1077
|
+
const queryText = buffer.map((item) => item.entry ? formatHistoryEntry(item.entry) : "").filter(Boolean).join("\n");
|
|
1078
|
+
const contentText = buffer.map((item) => item.contentText).filter(Boolean).join("\n");
|
|
1079
|
+
this.beginCooldown(channelKeyOf(first.session.platform, first.session.channelId), rule.name, "message");
|
|
1080
|
+
this.run({
|
|
1081
|
+
session: first.session,
|
|
1082
|
+
platform: first.session.platform,
|
|
1083
|
+
selfId: first.session.selfId,
|
|
1084
|
+
channelId: first.session.channelId,
|
|
1085
|
+
guildId: first.session.guildId ?? first.session.channelId,
|
|
1086
|
+
userId: first.session.userId,
|
|
1087
|
+
triggerType: "merged",
|
|
812
1088
|
rule,
|
|
813
|
-
queryText
|
|
1089
|
+
queryText,
|
|
1090
|
+
contentText
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
/** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
|
|
1094
|
+
fireDensityExpiry(rule, conditionIndex, channel) {
|
|
1095
|
+
this.beginCooldown(channelKeyOf(channel.platform, channel.channelId), rule.name, densityLane(conditionIndex));
|
|
1096
|
+
const windowText = formatHistoryEntries(channel.entries);
|
|
1097
|
+
this.run({
|
|
1098
|
+
platform: channel.platform,
|
|
1099
|
+
selfId: channel.selfId,
|
|
1100
|
+
channelId: channel.channelId,
|
|
1101
|
+
guildId: channel.guildId,
|
|
1102
|
+
triggerType: "density",
|
|
1103
|
+
rule,
|
|
1104
|
+
queryText: windowText,
|
|
1105
|
+
contentText: windowText
|
|
814
1106
|
});
|
|
815
1107
|
}
|
|
816
1108
|
/**
|
|
817
|
-
*
|
|
818
|
-
*
|
|
1109
|
+
* 定时触发(消息摘要):把消息池内该规则作用域有消息的每个频道各发一次请求(取走即清空全池)。
|
|
1110
|
+
* 频道身份取自池内最近一条消息;无消息的频道自然跳过。定时不受冷却影响。
|
|
819
1111
|
*/
|
|
820
1112
|
fireSchedule(rule) {
|
|
821
|
-
for (const channel of this.
|
|
1113
|
+
for (const channel of this.pool.takeSchedule(rule)) {
|
|
1114
|
+
const windowText = formatHistoryEntries(channel.entries);
|
|
822
1115
|
this.run({
|
|
823
1116
|
platform: channel.platform,
|
|
824
1117
|
selfId: channel.selfId,
|
|
@@ -826,12 +1119,40 @@ var TriggerEngine = class {
|
|
|
826
1119
|
guildId: channel.guildId,
|
|
827
1120
|
triggerType: "schedule",
|
|
828
1121
|
rule,
|
|
829
|
-
queryText:
|
|
1122
|
+
queryText: windowText,
|
|
1123
|
+
contentText: windowText
|
|
830
1124
|
});
|
|
831
1125
|
}
|
|
832
1126
|
}
|
|
1127
|
+
/** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
|
|
1128
|
+
tickCooldown() {
|
|
1129
|
+
if (!this.cooldown.enabled) return;
|
|
1130
|
+
const now = Date.now();
|
|
1131
|
+
for (const expired of this.cooldown.collectExpired(now)) {
|
|
1132
|
+
this.onCooldownExpired(expired, now);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
onCooldownExpired(expired, now) {
|
|
1136
|
+
const rule = this.rules.find((item) => item.name === expired.ruleName);
|
|
1137
|
+
if (!rule) return;
|
|
1138
|
+
if (expired.lane === "message") {
|
|
1139
|
+
if (expired.buffer.length) this.fireMerged(rule, expired.buffer);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const index = Number(expired.lane.slice("density:".length));
|
|
1143
|
+
const condition = rule.conditions[index];
|
|
1144
|
+
if (condition?.type !== "density") return;
|
|
1145
|
+
const channel = this.pool.takeDensityChannel(rule, condition, expired.channelKey, now);
|
|
1146
|
+
if (channel) this.fireDensityExpiry(rule, index, channel);
|
|
1147
|
+
}
|
|
833
1148
|
};
|
|
834
1149
|
|
|
1150
|
+
// src/types.ts
|
|
1151
|
+
function isMessageTrigger(type) {
|
|
1152
|
+
return type !== "schedule" && type !== "density";
|
|
1153
|
+
}
|
|
1154
|
+
__name(isMessageTrigger, "isMessageTrigger");
|
|
1155
|
+
|
|
835
1156
|
// src/requester/files.ts
|
|
836
1157
|
var import_promises = require("fs/promises");
|
|
837
1158
|
var import_fs = require("fs");
|
|
@@ -965,19 +1286,19 @@ function resolveFileType(mimeType, elementType) {
|
|
|
965
1286
|
return mime === "custom" && elementType !== "custom" ? elementType : mime;
|
|
966
1287
|
}
|
|
967
1288
|
__name(resolveFileType, "resolveFileType");
|
|
968
|
-
async function uploadOne(ctx, client, candidate, difyUser) {
|
|
1289
|
+
async function uploadOne(ctx, client, candidate, difyUser, limit) {
|
|
969
1290
|
try {
|
|
970
1291
|
const payload = await resolveFilePayload(ctx, candidate);
|
|
971
1292
|
if (!payload) return null;
|
|
972
1293
|
const type = resolveFileType(payload.mimeType, candidate.type);
|
|
973
|
-
const
|
|
974
|
-
|
|
1294
|
+
const maxBytes = limit.maxSizeMb > 0 ? limit.maxSizeMb * MB : Infinity;
|
|
1295
|
+
const sizeLimit = Math.min(SIZE_LIMITS[type], maxBytes);
|
|
1296
|
+
if (payload.buffer.length > sizeLimit) {
|
|
975
1297
|
ctx.logger.warn(
|
|
976
|
-
"文件 %s (%dMB)
|
|
1298
|
+
"文件 %s (%dMB) 超过上限 %dMB,跳过",
|
|
977
1299
|
payload.fileName,
|
|
978
1300
|
Math.round(payload.buffer.length / MB),
|
|
979
|
-
|
|
980
|
-
Math.round(limit / MB)
|
|
1301
|
+
Math.round(sizeLimit / MB)
|
|
981
1302
|
);
|
|
982
1303
|
return null;
|
|
983
1304
|
}
|
|
@@ -992,14 +1313,17 @@ async function uploadOne(ctx, client, candidate, difyUser) {
|
|
|
992
1313
|
}
|
|
993
1314
|
}
|
|
994
1315
|
__name(uploadOne, "uploadOne");
|
|
995
|
-
async function uploadMessageFiles(ctx, client, session, difyUser) {
|
|
1316
|
+
async function uploadMessageFiles(ctx, client, session, difyUser, limit) {
|
|
996
1317
|
const candidates = extractFileCandidates(session);
|
|
1318
|
+
const limited = limit.maxCount > 0 ? candidates.slice(0, limit.maxCount) : candidates;
|
|
1319
|
+
const dropped = candidates.slice(limited.length).map((candidate) => candidate.source);
|
|
997
1320
|
const files = [];
|
|
998
|
-
for (const candidate of
|
|
999
|
-
const uploaded = await uploadOne(ctx, client, candidate, difyUser);
|
|
1321
|
+
for (const candidate of limited) {
|
|
1322
|
+
const uploaded = await uploadOne(ctx, client, candidate, difyUser, limit);
|
|
1000
1323
|
if (uploaded) files.push(uploaded);
|
|
1324
|
+
else dropped.push(candidate.source);
|
|
1001
1325
|
}
|
|
1002
|
-
return files;
|
|
1326
|
+
return { files, dropped };
|
|
1003
1327
|
}
|
|
1004
1328
|
__name(uploadMessageFiles, "uploadMessageFiles");
|
|
1005
1329
|
|
|
@@ -1020,7 +1344,7 @@ function resolveSessionField(key, fireCtx, now) {
|
|
|
1020
1344
|
case "messageId":
|
|
1021
1345
|
return session?.messageId ?? "";
|
|
1022
1346
|
case "content":
|
|
1023
|
-
return fireCtx.
|
|
1347
|
+
return fireCtx.contentText;
|
|
1024
1348
|
case "quoteContent":
|
|
1025
1349
|
return parseTextSafe(session?.quote?.content);
|
|
1026
1350
|
case "quoteAuthorId":
|
|
@@ -1055,6 +1379,8 @@ function resolveSource(source, fireCtx, history, now) {
|
|
|
1055
1379
|
return resolveSessionField(source.key, fireCtx, now);
|
|
1056
1380
|
case "meta":
|
|
1057
1381
|
return resolveMetaField(source.key, fireCtx, now);
|
|
1382
|
+
case "constant":
|
|
1383
|
+
return source.value;
|
|
1058
1384
|
case "history": {
|
|
1059
1385
|
const channelKey = channelKeyOf(fireCtx.platform, fireCtx.channelId);
|
|
1060
1386
|
const limit = source.limit ?? history.limit;
|
|
@@ -1113,7 +1439,7 @@ function renderReply(input) {
|
|
|
1113
1439
|
const segments = splitText(input.text, input.config.maxSegmentLength ?? 0);
|
|
1114
1440
|
segments.forEach((segment, index) => {
|
|
1115
1441
|
const elements = [];
|
|
1116
|
-
if (index === 0 && input.
|
|
1442
|
+
if (index === 0 && input.quoteMessageId) {
|
|
1117
1443
|
elements.push(import_koishi3.h.quote(input.quoteMessageId));
|
|
1118
1444
|
}
|
|
1119
1445
|
if (input.config.parseElements) {
|
|
@@ -1138,7 +1464,7 @@ function errorMessage(error) {
|
|
|
1138
1464
|
__name(errorMessage, "errorMessage");
|
|
1139
1465
|
|
|
1140
1466
|
// src/requester/runner.ts
|
|
1141
|
-
var FALLBACK_QUERY = "
|
|
1467
|
+
var FALLBACK_QUERY = "(本次触发无文本内容,请参考变量中的上下文)";
|
|
1142
1468
|
var SUCCESS_STATUSES = ["succeeded", "partial-succeeded"];
|
|
1143
1469
|
var FALLBACK_APP_TYPE = "chat";
|
|
1144
1470
|
function isFileObject(value) {
|
|
@@ -1192,6 +1518,7 @@ var RequestRunner = class {
|
|
|
1192
1518
|
return app.appType !== "auto" ? app.appType : this.appTypes.get(app.name) ?? FALLBACK_APP_TYPE;
|
|
1193
1519
|
}
|
|
1194
1520
|
async run(fireCtx) {
|
|
1521
|
+
const startedAt = Date.now();
|
|
1195
1522
|
const { rule } = fireCtx;
|
|
1196
1523
|
const app = this.apps.get(rule.app);
|
|
1197
1524
|
if (!app) {
|
|
@@ -1206,8 +1533,19 @@ var RequestRunner = class {
|
|
|
1206
1533
|
inputs: resolveVariables(rule, fireCtx, this.history),
|
|
1207
1534
|
uploadedFiles: []
|
|
1208
1535
|
};
|
|
1209
|
-
|
|
1210
|
-
|
|
1536
|
+
const upload = resolveUploadConfig(this.config);
|
|
1537
|
+
if (upload.enabled && fireCtx.session) {
|
|
1538
|
+
const { files, dropped } = await uploadMessageFiles(
|
|
1539
|
+
this.ctx,
|
|
1540
|
+
client,
|
|
1541
|
+
fireCtx.session,
|
|
1542
|
+
requestCtx.difyUser,
|
|
1543
|
+
{ maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
|
|
1544
|
+
);
|
|
1545
|
+
requestCtx.uploadedFiles = files;
|
|
1546
|
+
if (dropped.length) {
|
|
1547
|
+
fireCtx.queryText = annotateDroppedPlaceholders(fireCtx.queryText, dropped);
|
|
1548
|
+
}
|
|
1211
1549
|
}
|
|
1212
1550
|
let reply;
|
|
1213
1551
|
try {
|
|
@@ -1224,10 +1562,13 @@ var RequestRunner = class {
|
|
|
1224
1562
|
this.ctx.logger.warn("规则 %s 的请求未返回任何内容", rule.name);
|
|
1225
1563
|
return;
|
|
1226
1564
|
}
|
|
1565
|
+
const quote = resolveQuoteConfig(this.config);
|
|
1566
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1e3;
|
|
1567
|
+
const shouldQuote = quote.enabled && elapsedSeconds >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
|
|
1227
1568
|
const messages = renderReply({
|
|
1228
1569
|
text: reply.text,
|
|
1229
1570
|
files: reply.files,
|
|
1230
|
-
quoteMessageId: fireCtx.session?.messageId,
|
|
1571
|
+
quoteMessageId: shouldQuote ? fireCtx.session?.messageId : void 0,
|
|
1231
1572
|
config: this.config
|
|
1232
1573
|
});
|
|
1233
1574
|
await this.deliver(fireCtx, messages);
|
|
@@ -1431,12 +1772,12 @@ function apply(ctx, config) {
|
|
|
1431
1772
|
ctx.i18n.define("zh-CN", zh_CN_default);
|
|
1432
1773
|
ctx.i18n.define("en-US", en_US_default);
|
|
1433
1774
|
defineTables(ctx);
|
|
1434
|
-
const { apps, rules } = normalizeConfig(config);
|
|
1775
|
+
const { apps, rules, cooldown } = normalizeConfig(config);
|
|
1435
1776
|
const appTypes = /* @__PURE__ */ new Map();
|
|
1436
1777
|
const history = new MessageHistory(config.historyLimit);
|
|
1437
1778
|
const conversationStore = new ConversationStore(ctx, apps);
|
|
1438
1779
|
const runner = new RequestRunner(ctx, config, conversationStore, history, apps, appTypes);
|
|
1439
|
-
const engine = new TriggerEngine(ctx, runner, history, rules);
|
|
1780
|
+
const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
|
|
1440
1781
|
ctx.on("ready", async () => {
|
|
1441
1782
|
await probeAppTypes(ctx, config, apps, appTypes);
|
|
1442
1783
|
engine.start();
|
package/lib/requester/files.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export declare function mimeToFileType(mimeType?: string): DifyFileType;
|
|
|
17
17
|
export declare function extractFileCandidates(session: Session): FileCandidate[];
|
|
18
18
|
/** 三源归一:data URL / 远程 URL / 本地路径 → {buffer, fileName, mimeType}(冒烟测试直测) */
|
|
19
19
|
export declare function resolveFilePayload(ctx: Context, candidate: FileCandidate): Promise<FilePayload | null>;
|
|
20
|
-
/**
|
|
21
|
-
export
|
|
20
|
+
/** 上传数量与单文件大小限制(0 不限;大小与 Dify 各类型上限取较小值) */
|
|
21
|
+
export interface UploadLimit {
|
|
22
|
+
maxCount: number;
|
|
23
|
+
maxSizeMb: number;
|
|
24
|
+
}
|
|
25
|
+
/** 一次上传的产物:成功的 files 载荷 + 被丢弃候选的来源 URL(截断/超限/失败统一记录,供占位符注记) */
|
|
26
|
+
export interface UploadResult {
|
|
27
|
+
files: DifyInputFileObject[];
|
|
28
|
+
dropped: string[];
|
|
29
|
+
}
|
|
30
|
+
/** 提取并上传消息中的文件(先按数量上限截断候选,再逐个上传) */
|
|
31
|
+
export declare function uploadMessageFiles(ctx: Context, client: DifyClient, session: Session, difyUser: string, limit: UploadLimit): Promise<UploadResult>;
|
|
22
32
|
export {};
|
|
@@ -2,7 +2,7 @@ import { Session } from 'koishi';
|
|
|
2
2
|
import { MessageTriggerType, TriggerCondition, TriggerScope } from '../types';
|
|
3
3
|
export interface ConditionMatched {
|
|
4
4
|
type: MessageTriggerType;
|
|
5
|
-
/** 触发后用作
|
|
5
|
+
/** 触发后用作 content 变量的剥离文本(query 由引擎按统一消息格式另行构造) */
|
|
6
6
|
queryText: string;
|
|
7
7
|
}
|
|
8
8
|
/** 评估单个消息条件;返回 null 表示未命中 */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Session } from 'koishi';
|
|
2
|
+
import { CooldownSetting } from '../types';
|
|
3
|
+
import { HistoryEntry } from '../history';
|
|
4
|
+
/** 冷却期内缓存的一条触发消息(entry 供统一格式渲染,纯 @ 等无文本消息为空) */
|
|
5
|
+
export interface PendingMessage {
|
|
6
|
+
session: Session;
|
|
7
|
+
entry: HistoryEntry | null;
|
|
8
|
+
/** 该条消息命中条件时剥离出的文本(contentText 用) */
|
|
9
|
+
contentText: string;
|
|
10
|
+
}
|
|
11
|
+
/** 冷却通道:message = 规则内全部消息类条件共享;density:N = 第 N 个密度条件独立 */
|
|
12
|
+
export type CooldownLane = 'message' | `density:${number}`;
|
|
13
|
+
/** 密度条件的通道名 */
|
|
14
|
+
export declare function densityLane(index: number): CooldownLane;
|
|
15
|
+
/** 到期扫描交出的通道产物 */
|
|
16
|
+
export interface ExpiredCooldown {
|
|
17
|
+
channelKey: string;
|
|
18
|
+
ruleName: string;
|
|
19
|
+
lane: CooldownLane;
|
|
20
|
+
buffer: PendingMessage[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 触发后冷却:从触发发起时刻计时(请求耗时计入冷却)。
|
|
24
|
+
* 消息通道冷却期内命中消息进缓冲并顺延滑动窗口,到期合并为一次触发;
|
|
25
|
+
* 密度通道冷却期即积攒期(池照常记录),全部过路消息顺延,到期复查达标才触发。
|
|
26
|
+
* 定时触发不参与冷却。配置关闭时所有方法零开销直退。
|
|
27
|
+
*/
|
|
28
|
+
export declare class TriggerCooldown {
|
|
29
|
+
private setting;
|
|
30
|
+
/** channelKey → (规则名+通道 → 冷却态) */
|
|
31
|
+
private channels;
|
|
32
|
+
constructor(setting: CooldownSetting | null);
|
|
33
|
+
get enabled(): boolean;
|
|
34
|
+
/** 触发发起时登记冷却 */
|
|
35
|
+
begin(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
|
|
36
|
+
isCooling(channelKey: string, ruleName: string, lane: CooldownLane, now: number): boolean;
|
|
37
|
+
/** 滑动窗口顺延:截止时间顺延到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
|
|
38
|
+
extend(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
|
|
39
|
+
/** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
|
|
40
|
+
extendDensityLanes(channelKey: string, now: number): void;
|
|
41
|
+
/** 冷却期内缓存一条命中消息(仅消息通道) */
|
|
42
|
+
bufferMessage(channelKey: string, ruleName: string, message: PendingMessage): void;
|
|
43
|
+
/** 到期扫描:取走全部到期通道(连缓冲一并交出),由调用方决定是否重新登记 */
|
|
44
|
+
collectExpired(now: number): ExpiredCooldown[];
|
|
45
|
+
private lanesOf;
|
|
46
|
+
}
|
package/lib/trigger/engine.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
-
import { DifyRule } from '../types';
|
|
2
|
+
import { CooldownSetting, DifyRule } from '../types';
|
|
3
3
|
import { MessageHistory } from '../history';
|
|
4
4
|
import { RequestRunner } from '../requester/runner';
|
|
5
5
|
/**
|
|
@@ -12,19 +12,28 @@ export declare class TriggerEngine {
|
|
|
12
12
|
private history;
|
|
13
13
|
private rules;
|
|
14
14
|
private scheduler;
|
|
15
|
-
private
|
|
16
|
-
private
|
|
17
|
-
constructor(ctx: Context, runner: RequestRunner, history: MessageHistory, rules: DifyRule[]);
|
|
15
|
+
private pool;
|
|
16
|
+
private cooldown;
|
|
17
|
+
constructor(ctx: Context, runner: RequestRunner, history: MessageHistory, rules: DifyRule[], cooldown: CooldownSetting | null);
|
|
18
18
|
start(): void;
|
|
19
19
|
private handleMessage;
|
|
20
20
|
private matchRule;
|
|
21
21
|
/** 异步执行一次请求;错误由请求器与这里的兜底记录 */
|
|
22
22
|
private run;
|
|
23
|
+
/** 登记冷却(定时触发不调用;冷却关闭时零开销直退) */
|
|
24
|
+
private beginCooldown;
|
|
23
25
|
private fireMessage;
|
|
24
26
|
private fireDensity;
|
|
27
|
+
/** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
|
|
28
|
+
private fireMerged;
|
|
29
|
+
/** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
|
|
30
|
+
private fireDensityExpiry;
|
|
25
31
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* 定时触发(消息摘要):把消息池内该规则作用域有消息的每个频道各发一次请求(取走即清空全池)。
|
|
33
|
+
* 频道身份取自池内最近一条消息;无消息的频道自然跳过。定时不受冷却影响。
|
|
28
34
|
*/
|
|
29
35
|
private fireSchedule;
|
|
36
|
+
/** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
|
|
37
|
+
private tickCooldown;
|
|
38
|
+
private onCooldownExpired;
|
|
30
39
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Session } from 'koishi';
|
|
2
|
+
import { DensityCondition, DifyRule } from '../types';
|
|
3
|
+
import { HistoryEntry } from '../history';
|
|
4
|
+
/** 一个频道的缓冲池:密度与定时共用,谁先触发谁取走清空 */
|
|
5
|
+
export interface ChannelPool {
|
|
6
|
+
platform: string;
|
|
7
|
+
channelId: string;
|
|
8
|
+
guildId?: string;
|
|
9
|
+
/** 定时投递凭据,跟随最近一条消息的 bot */
|
|
10
|
+
selfId: string;
|
|
11
|
+
isDirect?: boolean;
|
|
12
|
+
entries: HistoryEntry[];
|
|
13
|
+
}
|
|
14
|
+
export interface DensityFire {
|
|
15
|
+
rule: DifyRule;
|
|
16
|
+
/** 命中的密度条件与其在规则内的下标(密度冷却通道键) */
|
|
17
|
+
condition: DensityCondition;
|
|
18
|
+
conditionIndex: number;
|
|
19
|
+
/** 触发时取走的该频道全池消息(含 bot 行与时效外条目) */
|
|
20
|
+
entries: HistoryEntry[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 频道消息池:密度与定时触发共用的缓冲层。
|
|
24
|
+
* 密度只作触发依据(时效内真人条数达阈值),触发即取走全池;
|
|
25
|
+
* 定时到点取走作用域内有消息的频道全池——两者天然互相清空、重新积累。
|
|
26
|
+
*/
|
|
27
|
+
export declare class MessagePool {
|
|
28
|
+
private channels;
|
|
29
|
+
/** 一条消息(含 bot 自己的发言)入池,全量记录不做规则过滤 */
|
|
30
|
+
record(session: Session, entry: HistoryEntry): void;
|
|
31
|
+
/** 时效内且通过规则作用域的真人条数(bot 行不推高密度,防自激励) */
|
|
32
|
+
private countQualified;
|
|
33
|
+
/**
|
|
34
|
+
* 新消息入池后的密度评估:时效内(相对触发消息)且通过规则作用域的真人条数达到阈值。
|
|
35
|
+
* 触发即取走并清空该频道全池(bot 行不推高密度,防自激励);池清空后其余规则无从触发。
|
|
36
|
+
* skip 返回 true 的条件(冷却中的密度条件)不参与判定。
|
|
37
|
+
*/
|
|
38
|
+
checkDensity(rules: DifyRule[], session: Session, skip?: (rule: DifyRule, condition: DensityCondition, index: number) => boolean): DensityFire[];
|
|
39
|
+
/** 冷却到期复查:按条件参数统计该频道池(now 为复查时刻),达标即取走整池,未达标返回 null */
|
|
40
|
+
takeDensityChannel(rule: DifyRule, condition: DensityCondition, channelKey: string, now: number): ChannelPool | null;
|
|
41
|
+
/** 定时到点:取走该规则作用域内有消息的所有频道(整池清空,含未过作用域的条目) */
|
|
42
|
+
takeSchedule(rule: DifyRule): ChannelPool[];
|
|
43
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Session } from 'koishi';
|
|
2
|
-
/**
|
|
3
|
-
export type MessageTriggerType = 'keyword-prefix' | 'keyword-contains' | 'at-bot' | 'at-user' | 'quote-bot' | 'quote-user';
|
|
2
|
+
/** 消息驱动的触发原因(merged = 冷却期内多条触发消息合并为一次触发) */
|
|
3
|
+
export type MessageTriggerType = 'keyword-prefix' | 'keyword-contains' | 'at-bot' | 'at-user' | 'quote-bot' | 'quote-user' | 'merged';
|
|
4
4
|
/** 非消息驱动的触发原因 */
|
|
5
5
|
export type ProactiveTriggerType = 'schedule' | 'density';
|
|
6
6
|
export type TriggerType = MessageTriggerType | ProactiveTriggerType;
|
|
7
|
+
/** 是否由消息触发(合并触发也算):定时与密度触发没有单一触发消息 */
|
|
8
|
+
export declare function isMessageTrigger(type: TriggerType): boolean;
|
|
7
9
|
/** 关键词条件(prefix 触发时剥离关键词作为 query,contains 保留全文) */
|
|
8
10
|
export interface KeywordCondition {
|
|
9
11
|
type: 'keyword-prefix' | 'keyword-contains';
|
|
@@ -63,6 +65,9 @@ export type VariableSource = {
|
|
|
63
65
|
} | {
|
|
64
66
|
type: 'meta';
|
|
65
67
|
key: MetaField;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'constant';
|
|
70
|
+
value: string;
|
|
66
71
|
};
|
|
67
72
|
/** 一条变量映射:Dify inputs 字段名 ← 来源 */
|
|
68
73
|
export interface VariableBinding {
|
|
@@ -71,6 +76,15 @@ export interface VariableBinding {
|
|
|
71
76
|
}
|
|
72
77
|
/** Dify 会话绑定粒度 */
|
|
73
78
|
export type ConversationScope = 'per-user' | 'per-guild' | 'per-channel' | 'global';
|
|
79
|
+
/** 触发后冷却设置(schema 规范化后的内部形态;null 表示关闭) */
|
|
80
|
+
export type CooldownSetting = {
|
|
81
|
+
mode: 'fixed';
|
|
82
|
+
ms: number;
|
|
83
|
+
} | {
|
|
84
|
+
mode: 'sliding';
|
|
85
|
+
minMs: number;
|
|
86
|
+
maxMs: number;
|
|
87
|
+
};
|
|
74
88
|
/** 触发规则(配置页 schema 规范化后的内部形态;身份即 name,按优先级降序排列) */
|
|
75
89
|
export interface DifyRule {
|
|
76
90
|
name: string;
|
|
@@ -94,6 +108,8 @@ export interface FireContext {
|
|
|
94
108
|
userId?: string;
|
|
95
109
|
triggerType: TriggerType;
|
|
96
110
|
rule: DifyRule;
|
|
97
|
-
/**
|
|
111
|
+
/** 发给 Dify 的 query:统一消息格式(消息触发为单行,窗口触发为逐行拼接) */
|
|
98
112
|
queryText: string;
|
|
113
|
+
/** content 变量的值:消息触发为剥离文本,窗口触发为统一格式拼接 */
|
|
114
|
+
contentText: string;
|
|
99
115
|
}
|
|
@@ -12,10 +12,14 @@ interface ExtractTextOptions {
|
|
|
12
12
|
}
|
|
13
13
|
/** 会话消息纯文本:优先 elements,退回 content 解析;统一 trim */
|
|
14
14
|
export declare function extractText(session: Session, options?: ExtractTextOptions): string;
|
|
15
|
-
/**
|
|
15
|
+
/** 消息中的图片元素在文本形态里的占位符(图片无 URL 时的兜底形态) */
|
|
16
16
|
export declare const IMAGE_PLACEHOLDER = "[\u56FE\u7247]";
|
|
17
|
+
/** 图片占位符带 URL 摘要前 8 位:同 URL 同标识,query 里的占位符与上传文件可全局对应 */
|
|
18
|
+
export declare function imagePlaceholder(url?: string): string;
|
|
19
|
+
/** 给被丢弃图片的占位符追加已丢弃注记:按 URL 摘要全局定位,无需与元素顺序对齐 */
|
|
20
|
+
export declare function annotateDroppedPlaceholders(text: string, droppedUrls: string[]): string;
|
|
17
21
|
/**
|
|
18
|
-
* 带媒体占位符的消息文本:text 元素按序拼接,img/image 元素渲染为 [
|
|
22
|
+
* 带媒体占位符的消息文本:text 元素按序拼接,img/image 元素渲染为 [image:URL摘要],
|
|
19
23
|
* 供聊天记录呈现(历史消息变量 / 定时摘要 / 密度触发共用)。
|
|
20
24
|
*/
|
|
21
25
|
export declare function extractTextWithPlaceholders(session: Session): string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-dify-ai",
|
|
3
3
|
"description": "Invoke Dify apps (chatflow / workflow / completion) in Koishi with triggers and conversation bindings",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.5",
|
|
5
5
|
"author": "PinkElysia",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/PinkElysiaDev/dify-ai-plugin-for-Koishi#readme",
|
package/readme.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 特性
|
|
6
6
|
|
|
7
|
-
- **8 种触发条件**:关键词开头 / 包含关键词 / @ 机器人 / @ 指定用户 / 引用机器人 / 引用指定用户 / 定时摘要(Cron
|
|
7
|
+
- **8 种触发条件**:关键词开头 / 包含关键词 / @ 机器人 / @ 指定用户 / 引用机器人 / 引用指定用户 / 定时摘要(Cron 或固定间隔,把消息池内积攒的群消息合并请求一次,无消息则跳过)/ 消息密度(池内消息达到阈值即触发;两种触发共用消息池,谁先触发谁清空),同一规则多条件任一命中即触发
|
|
8
8
|
- **多应用多规则**:规则按优先级匹配,按平台 / Bot / 群聊 / 频道 / 用户限定作用域
|
|
9
9
|
- **会话绑定**:按群聊 / 用户 / 频道 / 全局粒度续接 Dify 会话,`dify.conv.reset` 可重置
|
|
10
10
|
- **变量系统**:把会话字段、历史消息、触发元信息映射为 Dify inputs
|
package/lib/trigger/density.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Session } from 'koishi';
|
|
2
|
-
import { DifyRule } from '../types';
|
|
3
|
-
import { HistoryEntry } from '../history';
|
|
4
|
-
/**
|
|
5
|
-
* 消息密度触发:按 (规则, 频道) 维护滑动窗口内的消息,
|
|
6
|
-
* 窗口内条数达到阈值即回调触发,并把窗口内这些消息交给触发方作 query 上下文。
|
|
7
|
-
* 触发后清空窗口,需重新积累即天然防连发。
|
|
8
|
-
*/
|
|
9
|
-
export declare class DensityTracker {
|
|
10
|
-
private onTrigger;
|
|
11
|
-
private windows;
|
|
12
|
-
constructor(onTrigger: (rule: DifyRule, session: Session, entries: HistoryEntry[]) => void);
|
|
13
|
-
/** 把一条消息计入所有 density 规则的窗口;达到阈值的窗口立即触发 */
|
|
14
|
-
record(rules: DifyRule[], session: Session, entry: HistoryEntry): void;
|
|
15
|
-
}
|
package/lib/trigger/digest.d.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { Session } from 'koishi';
|
|
2
|
-
import { DifyRule } from '../types';
|
|
3
|
-
import { HistoryEntry } from '../history';
|
|
4
|
-
/** 一个频道的定时窗口缓冲:身份字段取自该频道最近一条被记录的消息 */
|
|
5
|
-
export interface ChannelDigest {
|
|
6
|
-
platform: string;
|
|
7
|
-
channelId: string;
|
|
8
|
-
guildId?: string;
|
|
9
|
-
selfId: string;
|
|
10
|
-
entries: HistoryEntry[];
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* 定时触发的消息窗口收集器。
|
|
14
|
-
*
|
|
15
|
-
* 定时规则是「消息摘要」模型:持续收集作用域内各频道的消息,
|
|
16
|
-
* 到点(cron/间隔)把每个频道窗口内的消息合并为一次 Dify 请求,窗口空则跳过。
|
|
17
|
-
* take() 取走即清空——窗口即「上次触发至今」;规则各自独立缓冲,互不干扰。
|
|
18
|
-
*/
|
|
19
|
-
export declare class MessageDigest {
|
|
20
|
-
/** ruleName → channelKey → 窗口缓冲 */
|
|
21
|
-
private windows;
|
|
22
|
-
/** 把一条消息记入所有 schedule 规则(作用域匹配)的窗口 */
|
|
23
|
-
record(rules: DifyRule[], session: Session, entry: HistoryEntry): void;
|
|
24
|
-
/** 取走规则窗口内的全部非空频道缓冲(并清空该规则的窗口) */
|
|
25
|
-
take(rule: DifyRule): ChannelDigest[];
|
|
26
|
-
}
|