koishi-plugin-dify-ai 0.1.4 → 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 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;
@@ -48,6 +48,10 @@ export type VariableEntry = {
48
48
  source: 'meta';
49
49
  field: string;
50
50
  key: string;
51
+ } | {
52
+ source: 'constant';
53
+ field: string;
54
+ value: string;
51
55
  };
52
56
  /** 作用域的一行:维度 + 值 */
53
57
  export interface ScopeRow {
@@ -64,23 +68,71 @@ export interface RuleEntry {
64
68
  scope: ScopeRow[];
65
69
  variables: VariableEntry[];
66
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
+ };
67
97
  export interface Config {
68
98
  apps: AppConfigEntry[];
69
99
  rules: RuleEntry[];
70
100
  parseElements: boolean;
71
- quoteTrigger: boolean;
101
+ quote: QuoteEntry;
72
102
  maxSegmentLength: number;
73
- uploadFiles: boolean;
103
+ /** boolean 是 0.1.x 的遗留形态,仅迁移读取(true → 开启不限,false → 关闭) */
104
+ uploadFiles: UploadEntry | boolean;
105
+ /** 0.1.x 的布尔开关,仅用于迁移读取(true → 总是引用,false → 关闭) */
106
+ quoteTrigger?: boolean;
74
107
  requestTimeout: number;
75
108
  historyLimit: number;
76
109
  sendErrors: boolean;
110
+ cooldown: CooldownEntry;
77
111
  }
78
112
  export declare const Config: Schema<Config>;
79
113
  /**
80
- * 配置规范化:应用表 + 按优先级降序的规则列表。
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
+ * 配置规范化:应用表 + 按优先级降序的规则列表 + 冷却设置。
81
132
  * 坏条件/无名变量直接丢弃;引用不存在应用的规则保留但运行时会 warn 跳过。
82
133
  */
83
134
  export declare function normalizeConfig(config: Config): {
84
135
  apps: Map<string, AppConfigEntry>;
85
136
  rules: DifyRule[];
137
+ cooldown: CooldownSetting | null;
86
138
  };
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"),
@@ -106,6 +107,7 @@ var sessionKeySchema = import_koishi.Schema.union([
106
107
  import_koishi.Schema.const("isDirect").description("是否私聊")
107
108
  ]);
108
109
  var metaKeySchema = import_koishi.Schema.union([
110
+ import_koishi.Schema.const("none").description("无"),
109
111
  import_koishi.Schema.const("triggerType").description("触发类型"),
110
112
  import_koishi.Schema.const("ruleName").description("规则名"),
111
113
  import_koishi.Schema.const("triggerTime").description("触发时间")
@@ -114,8 +116,8 @@ var variableSchema = import_koishi.Schema.union([
114
116
  import_koishi.Schema.object({
115
117
  source: import_koishi.Schema.const("session").required(),
116
118
  field: import_koishi.Schema.string().required().description("字段名"),
117
- key: sessionKeySchema.default("content").description("变量")
118
- }).default({ source: "session", field: "", key: "content" }).description("会话字段"),
119
+ key: sessionKeySchema.default("none").description("变量")
120
+ }).default({ source: "session", field: "", key: "none" }).description("会话字段"),
119
121
  import_koishi.Schema.object({
120
122
  source: import_koishi.Schema.const("history").required(),
121
123
  field: import_koishi.Schema.string().required().description("字段名"),
@@ -124,8 +126,46 @@ var variableSchema = import_koishi.Schema.union([
124
126
  import_koishi.Schema.object({
125
127
  source: import_koishi.Schema.const("meta").required(),
126
128
  field: import_koishi.Schema.string().required().description("字段名"),
127
- key: metaKeySchema.default("triggerType").description("变量")
128
- }).default({ source: "meta", field: "", key: "triggerType" }).description("触发元信息")
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("开启")
129
169
  ]);
130
170
  var Config = import_koishi.Schema.intersect([
131
171
  import_koishi.Schema.object({
@@ -173,14 +213,15 @@ var Config = import_koishi.Schema.intersect([
173
213
  }).description("触发规则"),
174
214
  import_koishi.Schema.object({
175
215
  parseElements: import_koishi.Schema.boolean().default(false).description("解析回复中的消息元素"),
176
- quoteTrigger: import_koishi.Schema.boolean().default(true).description("引用触发消息"),
216
+ quote: quoteSchema.default({ mode: "on", minElapsedSeconds: 0 }).description("引用触发消息"),
177
217
  maxSegmentLength: import_koishi.Schema.natural().default(0).description("分段长度,0 不分段"),
178
- uploadFiles: import_koishi.Schema.boolean().default(true).description("上传消息中的文件")
218
+ uploadFiles: uploadSchema.default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("上传消息中的文件")
179
219
  }).description("回复行为"),
180
220
  import_koishi.Schema.object({
181
221
  requestTimeout: import_koishi.Schema.natural().role("ms").default(3e5).description("请求超时"),
182
222
  historyLimit: import_koishi.Schema.natural().default(20).min(1).description("历史消息条数"),
183
- sendErrors: import_koishi.Schema.boolean().default(false).description("失败时提示错误")
223
+ sendErrors: import_koishi.Schema.boolean().default(false).description("失败时提示错误"),
224
+ cooldown: cooldownSchema.default({ mode: "off" }).description("触发后冷却")
184
225
  }).description("运行参数")
185
226
  ]);
186
227
  var CONVERSATION_SCOPES = ["per-user", "per-guild", "per-channel", "global"];
@@ -269,17 +310,61 @@ __name(toScope, "toScope");
269
310
  function toVariable(entry) {
270
311
  const field = entry.field?.trim();
271
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
+ }
272
317
  if (entry.source === "meta") {
318
+ if (entry.key === "none") return null;
273
319
  return { field, source: { type: "meta", key: entry.key || "triggerType" } };
274
320
  }
275
321
  if (entry.source === "history") {
276
322
  const limit = Number(entry.limit);
277
323
  return { field, source: { type: "history", ...limit > 0 ? { limit } : {} } };
278
324
  }
279
- if (entry.key === "history") return null;
325
+ if (entry.key === "none" || entry.key === "history") return null;
280
326
  return { field, source: { type: "session", key: entry.key || "content" } };
281
327
  }
282
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");
283
368
  function normalizeConfig(config) {
284
369
  const apps = /* @__PURE__ */ new Map();
285
370
  for (const app of config.apps ?? []) {
@@ -302,7 +387,7 @@ function normalizeConfig(config) {
302
387
  });
303
388
  }
304
389
  rules.sort((a, b) => b.priority - a.priority);
305
- return { apps, rules };
390
+ return { apps, rules, cooldown: toCooldown(config.cooldown) };
306
391
  }
307
392
  __name(normalizeConfig, "normalizeConfig");
308
393
 
@@ -413,6 +498,7 @@ function createDifyClient(ctx, app, timeout) {
413
498
  __name(createDifyClient, "createDifyClient");
414
499
 
415
500
  // src/utils/session-text.ts
501
+ var import_crypto = require("crypto");
416
502
  var import_koishi2 = require("koishi");
417
503
  function parseTextSafe(content) {
418
504
  if (!content) return "";
@@ -432,6 +518,20 @@ function extractText(session, options = {}) {
432
518
  }
433
519
  __name(extractText, "extractText");
434
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");
435
535
  function extractTextWithPlaceholders(session) {
436
536
  const elements = session.elements?.length ? session.elements : (() => {
437
537
  try {
@@ -446,7 +546,7 @@ function extractTextWithPlaceholders(session) {
446
546
  const text = element.attrs?.["content"] ?? "";
447
547
  if (text) parts.push(text);
448
548
  } else if (element.type === "img" || element.type === "image") {
449
- parts.push(IMAGE_PLACEHOLDER);
549
+ parts.push(imagePlaceholder(element.attrs?.["url"] ?? element.attrs?.["src"]));
450
550
  }
451
551
  }
452
552
  return parts.join(" ").replace(/\s+/g, " ").trim();
@@ -597,7 +697,7 @@ function guildKeyOf(platform, guildId, channelId) {
597
697
  __name(guildKeyOf, "guildKeyOf");
598
698
 
599
699
  // src/trigger/pool.ts
600
- var POOL_CAPACITY = 256;
700
+ var POOL_CAPACITY = 1024;
601
701
  function scopeSessionOf(pool, entry) {
602
702
  return {
603
703
  platform: pool.platform,
@@ -637,30 +737,44 @@ var MessagePool = class {
637
737
  pool.entries.splice(0, pool.entries.length - POOL_CAPACITY);
638
738
  }
639
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;
746
+ }
640
747
  /**
641
748
  * 新消息入池后的密度评估:时效内(相对触发消息)且通过规则作用域的真人条数达到阈值。
642
749
  * 触发即取走并清空该频道全池(bot 行不推高密度,防自激励);池清空后其余规则无从触发。
750
+ * skip 返回 true 的条件(冷却中的密度条件)不参与判定。
643
751
  */
644
- checkDensity(rules, session) {
752
+ checkDensity(rules, session, skip) {
645
753
  const key = channelKeyOf(session.platform, session.channelId);
646
754
  const pool = this.channels.get(key);
647
755
  if (!pool) return [];
648
756
  const now = session.timestamp ?? Date.now();
649
757
  for (const rule of rules) {
650
- for (const condition of rule.conditions) {
758
+ for (let index = 0; index < rule.conditions.length; index++) {
759
+ const condition = rule.conditions[index];
651
760
  if (condition.type !== "density") continue;
652
- const windowMs = condition.densityWindowSeconds * 1e3;
653
- const count = pool.entries.filter(
654
- (item) => item.role === "user" && now - item.timestamp <= windowMs && matchScope(rule.scope, scopeSessionOf(pool, item))
655
- ).length;
656
- if (count >= condition.densityCount) {
761
+ if (skip?.(rule, condition, index)) continue;
762
+ if (this.countQualified(pool, rule, condition, now) >= condition.densityCount) {
657
763
  this.channels.delete(key);
658
- return [{ rule, entries: pool.entries }];
764
+ return [{ rule, condition, conditionIndex: index, entries: pool.entries }];
659
765
  }
660
766
  }
661
767
  }
662
768
  return [];
663
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
+ }
664
778
  /** 定时到点:取走该规则作用域内有消息的所有频道(整池清空,含未过作用域的条目) */
665
779
  takeSchedule(rule) {
666
780
  const taken = [];
@@ -741,25 +855,128 @@ var RuleScheduler = class {
741
855
  }
742
856
  };
743
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
+
744
956
  // src/trigger/engine.ts
745
957
  var SCHEDULER_TICK_MS = 1e3;
746
958
  var TriggerEngine = class {
747
- constructor(ctx, runner, history, rules) {
959
+ constructor(ctx, runner, history, rules, cooldown) {
748
960
  this.ctx = ctx;
749
961
  this.runner = runner;
750
962
  this.history = history;
751
963
  this.rules = rules;
752
964
  this.scheduler = new RuleScheduler(ctx, (rule) => this.fireSchedule(rule));
753
965
  this.pool = new MessagePool();
966
+ this.cooldown = new TriggerCooldown(cooldown);
754
967
  }
755
968
  static {
756
969
  __name(this, "TriggerEngine");
757
970
  }
758
971
  scheduler;
759
972
  pool;
973
+ cooldown;
760
974
  start() {
761
975
  this.ctx.middleware((session, next) => this.handleMessage(session, next));
762
- this.ctx.setInterval(() => this.scheduler.tick(), SCHEDULER_TICK_MS);
976
+ this.ctx.setInterval(() => {
977
+ this.scheduler.tick();
978
+ this.tickCooldown();
979
+ }, SCHEDULER_TICK_MS);
763
980
  this.ctx.on("send", (session) => {
764
981
  const entry = session.channelId ? buildHistoryEntry(session, "assistant") : null;
765
982
  if (!entry) return;
@@ -773,17 +990,33 @@ var TriggerEngine = class {
773
990
  handleMessage(session, next) {
774
991
  if (this.ctx.bots[session.uid]) return next();
775
992
  if (!session.channelId) return next();
993
+ const channelKey = channelKeyOf(session.platform, session.channelId);
776
994
  const entry = buildHistoryEntry(session);
777
995
  if (entry) {
778
- this.history.recordEntry(channelKeyOf(session.platform, session.channelId), entry);
996
+ this.history.recordEntry(channelKey, entry);
779
997
  this.pool.record(session, entry);
780
- for (const fire of this.pool.checkDensity(this.rules, session)) {
781
- this.fireDensity(fire.rule, session, fire.entries);
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);
782
1006
  }
783
1007
  }
784
1008
  return next(async (nextMiddleware) => {
785
1009
  const matched = this.matchRule(session);
786
1010
  if (!matched) return nextMiddleware();
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
+ }
787
1020
  this.fireMessage(matched.rule, session, matched.condition, entry);
788
1021
  });
789
1022
  }
@@ -802,7 +1035,12 @@ var TriggerEngine = class {
802
1035
  run(fireCtx) {
803
1036
  void this.runner.run(fireCtx).catch((error) => this.ctx.logger.warn(error));
804
1037
  }
1038
+ /** 登记冷却(定时触发不调用;冷却关闭时零开销直退) */
1039
+ beginCooldown(channelKey, ruleName, lane) {
1040
+ this.cooldown.begin(channelKey, ruleName, lane, Date.now());
1041
+ }
805
1042
  fireMessage(rule, session, condition, entry) {
1043
+ this.beginCooldown(channelKeyOf(session.platform, session.channelId), rule.name, "message");
806
1044
  this.run({
807
1045
  session,
808
1046
  platform: session.platform,
@@ -817,8 +1055,9 @@ var TriggerEngine = class {
817
1055
  contentText: condition.queryText
818
1056
  });
819
1057
  }
820
- fireDensity(rule, session, entries) {
821
- const windowText = formatHistoryEntries(entries);
1058
+ fireDensity(fire, session) {
1059
+ this.beginCooldown(channelKeyOf(session.platform, session.channelId), fire.rule.name, densityLane(fire.conditionIndex));
1060
+ const windowText = formatHistoryEntries(fire.entries);
822
1061
  this.run({
823
1062
  session,
824
1063
  platform: session.platform,
@@ -827,6 +1066,40 @@ var TriggerEngine = class {
827
1066
  guildId: session.guildId ?? session.channelId,
828
1067
  userId: session.userId,
829
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",
1088
+ rule,
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",
830
1103
  rule,
831
1104
  queryText: windowText,
832
1105
  contentText: windowText
@@ -834,7 +1107,7 @@ var TriggerEngine = class {
834
1107
  }
835
1108
  /**
836
1109
  * 定时触发(消息摘要):把消息池内该规则作用域有消息的每个频道各发一次请求(取走即清空全池)。
837
- * 频道身份取自池内最近一条消息;无消息的频道自然跳过。
1110
+ * 频道身份取自池内最近一条消息;无消息的频道自然跳过。定时不受冷却影响。
838
1111
  */
839
1112
  fireSchedule(rule) {
840
1113
  for (const channel of this.pool.takeSchedule(rule)) {
@@ -851,8 +1124,35 @@ var TriggerEngine = class {
851
1124
  });
852
1125
  }
853
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
+ }
854
1148
  };
855
1149
 
1150
+ // src/types.ts
1151
+ function isMessageTrigger(type) {
1152
+ return type !== "schedule" && type !== "density";
1153
+ }
1154
+ __name(isMessageTrigger, "isMessageTrigger");
1155
+
856
1156
  // src/requester/files.ts
857
1157
  var import_promises = require("fs/promises");
858
1158
  var import_fs = require("fs");
@@ -986,19 +1286,19 @@ function resolveFileType(mimeType, elementType) {
986
1286
  return mime === "custom" && elementType !== "custom" ? elementType : mime;
987
1287
  }
988
1288
  __name(resolveFileType, "resolveFileType");
989
- async function uploadOne(ctx, client, candidate, difyUser) {
1289
+ async function uploadOne(ctx, client, candidate, difyUser, limit) {
990
1290
  try {
991
1291
  const payload = await resolveFilePayload(ctx, candidate);
992
1292
  if (!payload) return null;
993
1293
  const type = resolveFileType(payload.mimeType, candidate.type);
994
- const limit = SIZE_LIMITS[type];
995
- if (payload.buffer.length > limit) {
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) {
996
1297
  ctx.logger.warn(
997
- "文件 %s (%dMB) 超过 %s 类型上限 %dMB,跳过",
1298
+ "文件 %s (%dMB) 超过上限 %dMB,跳过",
998
1299
  payload.fileName,
999
1300
  Math.round(payload.buffer.length / MB),
1000
- type,
1001
- Math.round(limit / MB)
1301
+ Math.round(sizeLimit / MB)
1002
1302
  );
1003
1303
  return null;
1004
1304
  }
@@ -1013,14 +1313,17 @@ async function uploadOne(ctx, client, candidate, difyUser) {
1013
1313
  }
1014
1314
  }
1015
1315
  __name(uploadOne, "uploadOne");
1016
- async function uploadMessageFiles(ctx, client, session, difyUser) {
1316
+ async function uploadMessageFiles(ctx, client, session, difyUser, limit) {
1017
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);
1018
1320
  const files = [];
1019
- for (const candidate of candidates) {
1020
- const uploaded = await uploadOne(ctx, client, candidate, difyUser);
1321
+ for (const candidate of limited) {
1322
+ const uploaded = await uploadOne(ctx, client, candidate, difyUser, limit);
1021
1323
  if (uploaded) files.push(uploaded);
1324
+ else dropped.push(candidate.source);
1022
1325
  }
1023
- return files;
1326
+ return { files, dropped };
1024
1327
  }
1025
1328
  __name(uploadMessageFiles, "uploadMessageFiles");
1026
1329
 
@@ -1076,6 +1379,8 @@ function resolveSource(source, fireCtx, history, now) {
1076
1379
  return resolveSessionField(source.key, fireCtx, now);
1077
1380
  case "meta":
1078
1381
  return resolveMetaField(source.key, fireCtx, now);
1382
+ case "constant":
1383
+ return source.value;
1079
1384
  case "history": {
1080
1385
  const channelKey = channelKeyOf(fireCtx.platform, fireCtx.channelId);
1081
1386
  const limit = source.limit ?? history.limit;
@@ -1134,7 +1439,7 @@ function renderReply(input) {
1134
1439
  const segments = splitText(input.text, input.config.maxSegmentLength ?? 0);
1135
1440
  segments.forEach((segment, index) => {
1136
1441
  const elements = [];
1137
- if (index === 0 && input.config.quoteTrigger && input.quoteMessageId) {
1442
+ if (index === 0 && input.quoteMessageId) {
1138
1443
  elements.push(import_koishi3.h.quote(input.quoteMessageId));
1139
1444
  }
1140
1445
  if (input.config.parseElements) {
@@ -1213,6 +1518,7 @@ var RequestRunner = class {
1213
1518
  return app.appType !== "auto" ? app.appType : this.appTypes.get(app.name) ?? FALLBACK_APP_TYPE;
1214
1519
  }
1215
1520
  async run(fireCtx) {
1521
+ const startedAt = Date.now();
1216
1522
  const { rule } = fireCtx;
1217
1523
  const app = this.apps.get(rule.app);
1218
1524
  if (!app) {
@@ -1227,8 +1533,19 @@ var RequestRunner = class {
1227
1533
  inputs: resolveVariables(rule, fireCtx, this.history),
1228
1534
  uploadedFiles: []
1229
1535
  };
1230
- if (this.config.uploadFiles !== false && fireCtx.session) {
1231
- requestCtx.uploadedFiles = await uploadMessageFiles(this.ctx, client, fireCtx.session, requestCtx.difyUser);
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
+ }
1232
1549
  }
1233
1550
  let reply;
1234
1551
  try {
@@ -1245,10 +1562,13 @@ var RequestRunner = class {
1245
1562
  this.ctx.logger.warn("规则 %s 的请求未返回任何内容", rule.name);
1246
1563
  return;
1247
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);
1248
1568
  const messages = renderReply({
1249
1569
  text: reply.text,
1250
1570
  files: reply.files,
1251
- quoteMessageId: fireCtx.session?.messageId,
1571
+ quoteMessageId: shouldQuote ? fireCtx.session?.messageId : void 0,
1252
1572
  config: this.config
1253
1573
  });
1254
1574
  await this.deliver(fireCtx, messages);
@@ -1452,12 +1772,12 @@ function apply(ctx, config) {
1452
1772
  ctx.i18n.define("zh-CN", zh_CN_default);
1453
1773
  ctx.i18n.define("en-US", en_US_default);
1454
1774
  defineTables(ctx);
1455
- const { apps, rules } = normalizeConfig(config);
1775
+ const { apps, rules, cooldown } = normalizeConfig(config);
1456
1776
  const appTypes = /* @__PURE__ */ new Map();
1457
1777
  const history = new MessageHistory(config.historyLimit);
1458
1778
  const conversationStore = new ConversationStore(ctx, apps);
1459
1779
  const runner = new RequestRunner(ctx, config, conversationStore, history, apps, appTypes);
1460
- const engine = new TriggerEngine(ctx, runner, history, rules);
1780
+ const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
1461
1781
  ctx.on("ready", async () => {
1462
1782
  await probeAppTypes(ctx, config, apps, appTypes);
1463
1783
  engine.start();
@@ -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
- /** 提取并上传消息中的文件,返回可附加到请求体的 files 数组 */
21
- export declare function uploadMessageFiles(ctx: Context, client: DifyClient, session: Session, difyUser: string): Promise<DifyInputFileObject[]>;
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 {};
@@ -4,6 +4,7 @@ import { DifyFilePayload } from '../api/types';
4
4
  export interface RenderInput {
5
5
  text: string;
6
6
  files: DifyFilePayload[];
7
+ /** 引用哪条消息;调用方完成开关与耗时判定,未达标传空即不引用 */
7
8
  quoteMessageId?: string;
8
9
  /** 回复行为配置(koishi schema「回复行为」组) */
9
10
  config: Config;
@@ -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
+ }
@@ -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
  /**
@@ -13,17 +13,27 @@ export declare class TriggerEngine {
13
13
  private rules;
14
14
  private scheduler;
15
15
  private pool;
16
- constructor(ctx: Context, runner: RequestRunner, history: MessageHistory, rules: DifyRule[]);
16
+ private cooldown;
17
+ constructor(ctx: Context, runner: RequestRunner, history: MessageHistory, rules: DifyRule[], cooldown: CooldownSetting | null);
17
18
  start(): void;
18
19
  private handleMessage;
19
20
  private matchRule;
20
21
  /** 异步执行一次请求;错误由请求器与这里的兜底记录 */
21
22
  private run;
23
+ /** 登记冷却(定时触发不调用;冷却关闭时零开销直退) */
24
+ private beginCooldown;
22
25
  private fireMessage;
23
26
  private fireDensity;
27
+ /** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
28
+ private fireMerged;
29
+ /** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
30
+ private fireDensityExpiry;
24
31
  /**
25
32
  * 定时触发(消息摘要):把消息池内该规则作用域有消息的每个频道各发一次请求(取走即清空全池)。
26
- * 频道身份取自池内最近一条消息;无消息的频道自然跳过。
33
+ * 频道身份取自池内最近一条消息;无消息的频道自然跳过。定时不受冷却影响。
27
34
  */
28
35
  private fireSchedule;
36
+ /** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
37
+ private tickCooldown;
38
+ private onCooldownExpired;
29
39
  }
@@ -1,5 +1,5 @@
1
1
  import { Session } from 'koishi';
2
- import { DifyRule } from '../types';
2
+ import { DensityCondition, DifyRule } from '../types';
3
3
  import { HistoryEntry } from '../history';
4
4
  /** 一个频道的缓冲池:密度与定时共用,谁先触发谁取走清空 */
5
5
  export interface ChannelPool {
@@ -13,6 +13,9 @@ export interface ChannelPool {
13
13
  }
14
14
  export interface DensityFire {
15
15
  rule: DifyRule;
16
+ /** 命中的密度条件与其在规则内的下标(密度冷却通道键) */
17
+ condition: DensityCondition;
18
+ conditionIndex: number;
16
19
  /** 触发时取走的该频道全池消息(含 bot 行与时效外条目) */
17
20
  entries: HistoryEntry[];
18
21
  }
@@ -25,11 +28,16 @@ export declare class MessagePool {
25
28
  private channels;
26
29
  /** 一条消息(含 bot 自己的发言)入池,全量记录不做规则过滤 */
27
30
  record(session: Session, entry: HistoryEntry): void;
31
+ /** 时效内且通过规则作用域的真人条数(bot 行不推高密度,防自激励) */
32
+ private countQualified;
28
33
  /**
29
34
  * 新消息入池后的密度评估:时效内(相对触发消息)且通过规则作用域的真人条数达到阈值。
30
35
  * 触发即取走并清空该频道全池(bot 行不推高密度,防自激励);池清空后其余规则无从触发。
36
+ * skip 返回 true 的条件(冷却中的密度条件)不参与判定。
31
37
  */
32
- checkDensity(rules: DifyRule[], session: Session): DensityFire[];
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;
33
41
  /** 定时到点:取走该规则作用域内有消息的所有频道(整池清空,含未过作用域的条目) */
34
42
  takeSchedule(rule: DifyRule): ChannelPool[];
35
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;
@@ -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",
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",