koishi-plugin-dify-ai 0.1.7 → 0.1.10

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.
@@ -11,6 +11,7 @@ export declare class DifyClient {
11
11
  private timeout;
12
12
  constructor(http: Context['http'], app: DifyClientApp, timeout?: number);
13
13
  private buildUrl;
14
+ private authHeaders;
14
15
  private buildJsonHeaders;
15
16
  private errorFrom;
16
17
  /** 非 2xx 时抛出带状态码与 Dify 错误码的 DifyApiError */
package/lib/config.d.ts CHANGED
@@ -52,6 +52,9 @@ export type VariableEntry = {
52
52
  source: 'constant';
53
53
  field: string;
54
54
  value: string;
55
+ } | {
56
+ source: 'json';
57
+ template: string;
55
58
  };
56
59
  /** 作用域的一行:维度 + 值 */
57
60
  export interface ScopeRow {
@@ -68,13 +71,6 @@ export interface RuleEntry {
68
71
  scope: ScopeRow[];
69
72
  variables: VariableEntry[];
70
73
  }
71
- /** 引用触发消息的条件分支形态:关闭时空分支,开启时按耗时阈值决定是否引用 */
72
- export type QuoteEntry = {
73
- mode: 'off';
74
- } | {
75
- mode: 'on';
76
- minElapsedSeconds: number;
77
- };
78
74
  /** 触发后冷却的条件分支形态 */
79
75
  export type CooldownEntry = {
80
76
  mode: 'off';
@@ -86,33 +82,29 @@ export type CooldownEntry = {
86
82
  minSeconds?: number;
87
83
  maxSeconds?: number;
88
84
  };
89
- /** 上传消息文件的条件分支形态:关闭时空分支,开启时限制数量与单文件大小 */
90
- export type UploadEntry = {
91
- mode: 'off';
92
- } | {
93
- mode: 'on';
94
- maxCount?: number;
95
- maxSizeMb?: number;
96
- };
97
85
  export interface Config {
98
86
  apps: AppConfigEntry[];
99
87
  rules: RuleEntry[];
100
88
  parseElements: boolean;
101
- quote: QuoteEntry;
89
+ quote: boolean;
90
+ minElapsedSeconds: number;
102
91
  maxSegmentLength: number;
103
- /** boolean 是 0.1.x 的遗留形态,仅迁移读取(true → 开启不限,false → 关闭) */
104
- uploadFiles: UploadEntry | boolean;
105
- /** 0.1.x 的布尔开关,仅用于迁移读取(true → 总是引用,false → 关闭) */
106
- quoteTrigger?: boolean;
92
+ uploadFiles: boolean;
93
+ maxCount: number;
94
+ maxSizeMb: number;
107
95
  requestTimeout: number;
108
96
  historyLimit: number;
109
97
  sendErrors: boolean;
110
98
  cooldown: CooldownEntry;
111
99
  }
100
+ /**
101
+ * 上传消息文件:布尔开关驱动分支(开=可配数量与单文件大小上限,关=空分支),官方 union-tagged 写法。
102
+ * 上限默认 -1 规避 0 被表单默认值机制吞掉(-1 与 0 同为不限,仍受 Dify 各类型上限约束)。
103
+ */
112
104
  export declare const Config: Schema<Config>;
113
105
  /**
114
- * 上传消息文件配置(含 0.1.x 布尔遗留形态迁移):
115
- * 数量与单文件大小 0 均为不限,大小仍与 Dify 各类型上限取较小值。
106
+ * 上传消息文件配置(平铺开关,默认关闭:显式开启才上传)。数量与单文件大小
107
+ * 0 与 -1 均为不限,大小仍与 Dify 各类型上限取较小值。
116
108
  */
117
109
  export declare function resolveUploadConfig(config: Config): {
118
110
  enabled: boolean;
@@ -120,9 +112,8 @@ export declare function resolveUploadConfig(config: Config): {
120
112
  maxMb: number;
121
113
  };
122
114
  /**
123
- * 引用触发消息配置(含 0.1.x 布尔开关迁移):schema 会给旧配置填默认 quote 对象,
124
- * 因此迁移判据是「旧布尔被配置层透传保留 且 quote 仍是默认形态(用户未在新表单改过)」——
125
- * 此时以旧布尔为准;用户改过新表单(quote 非默认)则新值优先。
115
+ * 引用触发消息配置(平铺开关,含 0.1.x quoteTrigger 透传迁移:
116
+ * 旧布尔被保留且开关与阈值均未被改动时以旧布尔为准)。阈值 -1 与 0 同为总是引用。
126
117
  */
127
118
  export declare function resolveQuoteConfig(config: Config): {
128
119
  enabled: boolean;
package/lib/index.js CHANGED
@@ -49,6 +49,15 @@ var en_US_default = {
49
49
 
50
50
  // src/config.ts
51
51
  var import_koishi = require("koishi");
52
+
53
+ // src/types.ts
54
+ function isMessageTrigger(type) {
55
+ return type !== "schedule" && type !== "density";
56
+ }
57
+ __name(isMessageTrigger, "isMessageTrigger");
58
+ var JSON_PLACEHOLDER = /\{\{\s*(\w+)(?::(\d+))?\s*\}\}/g;
59
+
60
+ // src/config.ts
52
61
  var appTypeSchema = import_koishi.Schema.union([
53
62
  import_koishi.Schema.const("auto").description("自动探测"),
54
63
  import_koishi.Schema.const("chat").description("Chatbot"),
@@ -83,7 +92,7 @@ var conditionSchema = import_koishi.Schema.union([
83
92
  import_koishi.Schema.object({
84
93
  type: import_koishi.Schema.const("schedule").required(),
85
94
  cron: import_koishi.Schema.string().default("").description("Cron 表达式"),
86
- intervalMinutes: import_koishi.Schema.number().default(0).min(0).description("间隔分钟")
95
+ intervalMinutes: import_koishi.Schema.number().default(-1).min(-1).description("间隔分钟")
87
96
  }).default({ type: "schedule", cron: "", intervalMinutes: 0 }).description("定时"),
88
97
  import_koishi.Schema.object({
89
98
  type: import_koishi.Schema.const("density").required(),
@@ -121,7 +130,7 @@ var variableSchema = import_koishi.Schema.union([
121
130
  import_koishi.Schema.object({
122
131
  source: import_koishi.Schema.const("history").required(),
123
132
  field: import_koishi.Schema.string().required().description("字段名"),
124
- limit: import_koishi.Schema.natural().default(0).min(0).max(100).description("条数,0 用全局")
133
+ limit: import_koishi.Schema.number().default(-1).min(-1).max(100).description("条数,0 用全局")
125
134
  }).default({ source: "history", field: "", limit: 0 }).description("历史消息"),
126
135
  import_koishi.Schema.object({
127
136
  source: import_koishi.Schema.const("meta").required(),
@@ -132,16 +141,44 @@ var variableSchema = import_koishi.Schema.union([
132
141
  source: import_koishi.Schema.const("constant").required(),
133
142
  field: import_koishi.Schema.string().required().description("字段名"),
134
143
  value: import_koishi.Schema.string().role("textarea").required().description("固定文本")
135
- }).default({ source: "constant", field: "", value: "" }).description("常量文本")
144
+ }).default({ source: "constant", field: "", value: "" }).description("常量文本"),
145
+ import_koishi.Schema.object({
146
+ source: import_koishi.Schema.const("json").required(),
147
+ template: import_koishi.Schema.string().role("textarea").required().description("JSON 对象")
148
+ }).default({ source: "json", template: "" }).description("JSON 对象")
136
149
  ]);
137
- var quoteSchema = import_koishi.Schema.union([
150
+ var legacySwitch = import_koishi.Schema.transform(
151
+ import_koishi.Schema.any(),
152
+ (value) => typeof value === "boolean" ? value : value?.mode !== "off"
153
+ );
154
+ function switchSchema(defaultValue) {
155
+ return import_koishi.Schema.union([import_koishi.Schema.boolean().default(defaultValue), legacySwitch]).default(defaultValue);
156
+ }
157
+ __name(switchSchema, "switchSchema");
158
+ var quoteSwitchSchema = import_koishi.Schema.intersect([
138
159
  import_koishi.Schema.object({
139
- mode: import_koishi.Schema.const("off").required()
140
- }).default({ mode: "off" }).description("关闭"),
160
+ quote: switchSchema(true).description("引用触发消息")
161
+ }),
162
+ import_koishi.Schema.union([
163
+ import_koishi.Schema.object({
164
+ quote: import_koishi.Schema.const(true),
165
+ minElapsedSeconds: import_koishi.Schema.number().min(-1).default(-1).description("回复耗时多少秒才引用,0 总是引用")
166
+ }),
167
+ import_koishi.Schema.object({})
168
+ ])
169
+ ]);
170
+ var uploadSwitchSchema = import_koishi.Schema.intersect([
141
171
  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("开启")
172
+ uploadFiles: switchSchema(false).description("上传消息中的文件")
173
+ }),
174
+ import_koishi.Schema.union([
175
+ import_koishi.Schema.object({
176
+ uploadFiles: import_koishi.Schema.const(true).required(),
177
+ maxCount: import_koishi.Schema.number().min(-1).default(-1).description("数量上限,0 不限"),
178
+ maxSizeMb: import_koishi.Schema.number().min(-1).default(-1).description("单文件大小上限 MB,0 不限")
179
+ }),
180
+ import_koishi.Schema.object({})
181
+ ])
145
182
  ]);
146
183
  var cooldownSchema = import_koishi.Schema.union([
147
184
  import_koishi.Schema.object({
@@ -157,17 +194,6 @@ var cooldownSchema = import_koishi.Schema.union([
157
194
  maxSeconds: import_koishi.Schema.natural().default(120).min(1).description("最大冷却秒数")
158
195
  }).default({ mode: "sliding", minSeconds: 30, maxSeconds: 120 }).description("滑动窗口")
159
196
  ]);
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("开启"),
169
- import_koishi.Schema.boolean().description("旧版开关")
170
- ]);
171
197
  var Config = import_koishi.Schema.intersect([
172
198
  import_koishi.Schema.object({
173
199
  apps: import_koishi.Schema.array(
@@ -185,7 +211,7 @@ var Config = import_koishi.Schema.intersect([
185
211
  import_koishi.Schema.object({
186
212
  name: import_koishi.Schema.string().required().description("规则名"),
187
213
  enabled: import_koishi.Schema.boolean().default(true).description("启用"),
188
- priority: import_koishi.Schema.number().default(0).description("优先级,大者先匹配"),
214
+ priority: import_koishi.Schema.number().default(-1).description("优先级,大者先匹配"),
189
215
  app: import_koishi.Schema.string().required().description("应用名"),
190
216
  conversationScope: import_koishi.Schema.union([
191
217
  import_koishi.Schema.const("per-guild").description("按群聊"),
@@ -214,10 +240,10 @@ var Config = import_koishi.Schema.intersect([
214
240
  }).description("触发规则"),
215
241
  import_koishi.Schema.object({
216
242
  parseElements: import_koishi.Schema.boolean().default(false).description("解析回复中的消息元素"),
217
- quote: quoteSchema.default({ mode: "on", minElapsedSeconds: 0 }).description("引用触发消息"),
218
- maxSegmentLength: import_koishi.Schema.natural().default(0).description("分段长度,0 不分段"),
219
- uploadFiles: uploadSchema.default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("上传消息中的文件")
243
+ maxSegmentLength: import_koishi.Schema.number().min(-1).default(-1).description("分段长度,0 不分段")
220
244
  }).description("回复行为"),
245
+ quoteSwitchSchema,
246
+ uploadSwitchSchema,
221
247
  import_koishi.Schema.object({
222
248
  requestTimeout: import_koishi.Schema.natural().role("ms").default(3e5).description("请求超时"),
223
249
  historyLimit: import_koishi.Schema.natural().default(20).min(1).description("历史消息条数"),
@@ -309,6 +335,17 @@ function toScope(rows) {
309
335
  }
310
336
  __name(toScope, "toScope");
311
337
  function toVariable(entry) {
338
+ if (entry.source === "json") {
339
+ const template = entry.template ?? "";
340
+ if (!template.trim()) return null;
341
+ try {
342
+ const probe = JSON.parse(template.replace(JSON_PLACEHOLDER, "0"));
343
+ if (typeof probe !== "object" || probe === null || Array.isArray(probe)) return null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ return { field: "", source: { type: "json", template } };
348
+ }
312
349
  const field = entry.field?.trim();
313
350
  if (!field) return null;
314
351
  if (entry.source === "constant") {
@@ -328,25 +365,22 @@ function toVariable(entry) {
328
365
  }
329
366
  __name(toVariable, "toVariable");
330
367
  function resolveUploadConfig(config) {
331
- if (typeof config.uploadFiles === "boolean") {
332
- return config.uploadFiles ? { enabled: true, maxCount: 0, maxMb: 0 } : { enabled: false, maxCount: 0, maxMb: 0 };
333
- }
334
- if (config.uploadFiles?.mode === "off") return { enabled: false, maxCount: 0, maxMb: 0 };
368
+ if (config.uploadFiles !== true) return { enabled: false, maxCount: 0, maxMb: 0 };
335
369
  return {
336
370
  enabled: true,
337
- maxCount: Number(config.uploadFiles?.maxCount) || 0,
338
- maxMb: Number(config.uploadFiles?.maxSizeMb) || 0
371
+ maxCount: Number(config.maxCount) || 0,
372
+ maxMb: Number(config.maxSizeMb) || 0
339
373
  };
340
374
  }
341
375
  __name(resolveUploadConfig, "resolveUploadConfig");
342
376
  function resolveQuoteConfig(config) {
343
- const quote = config.quote;
344
- const isDefaultQuote = !quote || quote.mode === "on" && !(Number(quote.minElapsedSeconds) > 0);
345
- if (typeof config.quoteTrigger === "boolean" && isDefaultQuote) {
346
- return { enabled: config.quoteTrigger, minElapsedSeconds: 0 };
377
+ const threshold = Number(config.minElapsedSeconds) || 0;
378
+ const untouched = config.quote !== false && !(threshold > 0);
379
+ const legacy = config.quoteTrigger;
380
+ if (typeof legacy === "boolean" && untouched) {
381
+ return { enabled: legacy, minElapsedSeconds: 0 };
347
382
  }
348
- if (quote?.mode === "off") return { enabled: false, minElapsedSeconds: 0 };
349
- return { enabled: true, minElapsedSeconds: Number(quote?.minElapsedSeconds) || 0 };
383
+ return { enabled: config.quote !== false, minElapsedSeconds: threshold };
350
384
  }
351
385
  __name(resolveQuoteConfig, "resolveQuoteConfig");
352
386
  function toCooldown(entry) {
@@ -362,6 +396,7 @@ function toCooldown(entry) {
362
396
  }
363
397
  __name(toCooldown, "toCooldown");
364
398
  function normalizeConfig(config) {
399
+ config ??= {};
365
400
  const apps = /* @__PURE__ */ new Map();
366
401
  for (const app of config.apps ?? []) {
367
402
  if (app.name?.trim() && !apps.has(app.name)) apps.set(app.name, app);
@@ -419,11 +454,11 @@ var DifyClient = class {
419
454
  buildUrl(path) {
420
455
  return this.app.apiURL.replace(/\/+$/, "") + path;
421
456
  }
457
+ authHeaders() {
458
+ return { Authorization: `Bearer ${this.app.apiKey}` };
459
+ }
422
460
  buildJsonHeaders() {
423
- return {
424
- Authorization: `Bearer ${this.app.apiKey}`,
425
- "Content-Type": "application/json"
426
- };
461
+ return { ...this.authHeaders(), "Content-Type": "application/json" };
427
462
  }
428
463
  errorFrom(status, data, statusText = "") {
429
464
  const isObject = typeof data === "object" && data !== null;
@@ -473,7 +508,7 @@ var DifyClient = class {
473
508
  );
474
509
  form.set("user", user);
475
510
  const res = await this.http("POST", this.buildUrl("/files/upload"), {
476
- headers: { Authorization: `Bearer ${this.app.apiKey}` },
511
+ headers: this.authHeaders(),
477
512
  data: form,
478
513
  timeout: this.timeout,
479
514
  validateStatus: /* @__PURE__ */ __name(() => true, "validateStatus")
@@ -529,6 +564,14 @@ function annotateDroppedPlaceholders(text, droppedUrls) {
529
564
  return result;
530
565
  }
531
566
  __name(annotateDroppedPlaceholders, "annotateDroppedPlaceholders");
567
+ function isImageElement(element) {
568
+ return element.type === "img" || element.type === "image";
569
+ }
570
+ __name(isImageElement, "isImageElement");
571
+ function imageSourceOf(element) {
572
+ return element.attrs?.["url"] ?? element.attrs?.["src"];
573
+ }
574
+ __name(imageSourceOf, "imageSourceOf");
532
575
  function elementsOf(session) {
533
576
  if (session.elements?.length) return session.elements;
534
577
  try {
@@ -548,8 +591,8 @@ function extractTextWithPlaceholders(session, options = {}) {
548
591
  if (options.excludeBotAt && element.attrs?.["id"] === session.selfId) continue;
549
592
  const name2 = element.attrs?.["name"] ?? element.attrs?.["id"];
550
593
  if (name2) parts.push(`@${name2}`);
551
- } else if (element.type === "img" || element.type === "image") {
552
- parts.push(imagePlaceholder(element.attrs?.["url"] ?? element.attrs?.["src"]));
594
+ } else if (isImageElement(element)) {
595
+ parts.push(imagePlaceholder(imageSourceOf(element)));
553
596
  }
554
597
  }
555
598
  return parts.join(" ").replace(/\s+/g, " ").trim();
@@ -558,8 +601,8 @@ __name(extractTextWithPlaceholders, "extractTextWithPlaceholders");
558
601
  function extractImageUrls(session) {
559
602
  const urls = [];
560
603
  for (const element of elementsOf(session)) {
561
- if (element.type !== "img" && element.type !== "image") continue;
562
- const source = element.attrs?.["url"] ?? element.attrs?.["src"];
604
+ if (!isImageElement(element)) continue;
605
+ const source = imageSourceOf(element);
563
606
  if (typeof source === "string" && source) urls.push(source);
564
607
  }
565
608
  return urls;
@@ -578,7 +621,8 @@ __name(formatTimestamp, "formatTimestamp");
578
621
  function formatHistoryEntry(entry) {
579
622
  const quote = entry.quote ? `(引用了 [${formatTimestamp(entry.quote.timestamp)}] ${entry.quote.username} (${entry.quote.userId}) 说:${entry.quote.content})` : "";
580
623
  const messageId = entry.messageId ? ` [msg:${entry.messageId}]` : "";
581
- return `[${formatTimestamp(entry.timestamp)}] ${entry.username} (${entry.userId})${messageId}${quote}${quote ? "" : " "}说:${entry.content}`;
624
+ const separator = quote ? "" : " ";
625
+ return `[${formatTimestamp(entry.timestamp)}] ${entry.username} (${entry.userId})${messageId}${quote}${separator}说:${entry.content}`;
582
626
  }
583
627
  __name(formatHistoryEntry, "formatHistoryEntry");
584
628
  function formatHistoryEntries(entries) {
@@ -649,7 +693,10 @@ function hasAt(session, id) {
649
693
  return session.elements?.some((element) => element.type === "at" && element.attrs?.["id"] === id) ?? false;
650
694
  }
651
695
  __name(hasAt, "hasAt");
652
- var NON_TEXT_PREFIX = new RegExp(`^(?:@\\S+|\\[image:[0-9a-f]{${IMAGE_HASH_LENGTH}}\\]|\\[图片\\]|\\s)*`);
696
+ var escapeRegExp = /* @__PURE__ */ __name((text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "escapeRegExp");
697
+ var NON_TEXT_PREFIX = new RegExp(
698
+ `^(?:@\\S+|\\[image:[0-9a-f]{${IMAGE_HASH_LENGTH}}\\]|${escapeRegExp(IMAGE_PLACEHOLDER)}|\\s)*`
699
+ );
653
700
  function stripPrefixKeyword(verbatim, keyword) {
654
701
  const prefix = NON_TEXT_PREFIX.exec(verbatim)?.[0] ?? "";
655
702
  const rest = verbatim.slice(prefix.length);
@@ -709,8 +756,7 @@ function matchScope(scope, session) {
709
756
  ];
710
757
  if (dimensions.some(([allowed, value]) => allowed?.length && !allowed.includes(value))) return false;
711
758
  if (scope.excludeUsers?.length && scope.excludeUsers.includes(session.userId)) return false;
712
- if (scope.isDirect === true && !session.isDirect) return false;
713
- if (scope.isDirect === false && session.isDirect) return false;
759
+ if (scope.isDirect != null && scope.isDirect !== !!session.isDirect) return false;
714
760
  return true;
715
761
  }
716
762
  __name(matchScope, "matchScope");
@@ -790,7 +836,7 @@ var MessagePool = class {
790
836
  if (skip?.(rule, condition, index)) continue;
791
837
  if (this.countQualified(pool, rule, condition, now) >= condition.densityCount) {
792
838
  this.channels.delete(key);
793
- return [{ rule, condition, conditionIndex: index, entries: pool.entries }];
839
+ return [{ rule, conditionIndex: index, entries: pool.entries }];
794
840
  }
795
841
  }
796
842
  }
@@ -881,13 +927,13 @@ var RuleScheduler = class {
881
927
  // src/trigger/cooldown.ts
882
928
  var BUFFER_CAPACITY = 1024;
883
929
  var DENSITY_LANE_PREFIX = "density:";
930
+ var LANE_KEY_SEPARATOR = "\n";
884
931
  function densityLane(index) {
885
932
  return `${DENSITY_LANE_PREFIX}${index}`;
886
933
  }
887
934
  __name(densityLane, "densityLane");
888
935
  function laneKeyOf(ruleName, lane) {
889
- return `${ruleName}
890
- ${lane}`;
936
+ return `${ruleName}${LANE_KEY_SEPARATOR}${lane}`;
891
937
  }
892
938
  __name(laneKeyOf, "laneKeyOf");
893
939
  var TriggerCooldown = class {
@@ -923,23 +969,23 @@ var TriggerCooldown = class {
923
969
  state.fireAt + setting.maxMs
924
970
  );
925
971
  }
926
- /** 滑动窗口顺延:已到期的通道不复活 */
972
+ /** 滑动窗口顺延:sliding 模式且通道未到期才执行(已到期的不复活) */
973
+ slideIfActive(state, setting, now) {
974
+ if (setting?.mode !== "sliding" || !state || now >= state.deadline) return;
975
+ this.slide(state, setting, now);
976
+ }
977
+ /** 滑动窗口顺延 */
927
978
  extend(channelKey, ruleName, lane, now) {
928
- const setting = this.setting;
929
- if (setting?.mode !== "sliding") return;
930
979
  const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
931
- if (!state || now >= state.deadline) return;
932
- this.slide(state, setting, now);
980
+ this.slideIfActive(state, this.setting, now);
933
981
  }
934
- /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
982
+ /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
935
983
  extendDensityLanes(channelKey, now) {
936
- const setting = this.setting;
937
- if (setting?.mode !== "sliding") return;
938
984
  const lanes = this.channels.get(channelKey);
939
985
  if (!lanes) return;
940
986
  for (const [key, state] of lanes) {
941
- if (!key.includes("\ndensity:") || now >= state.deadline) continue;
942
- this.slide(state, setting, now);
987
+ if (!key.includes(LANE_KEY_SEPARATOR + DENSITY_LANE_PREFIX)) continue;
988
+ this.slideIfActive(state, this.setting, now);
943
989
  }
944
990
  }
945
991
  /** 冷却期内缓存一条命中消息(仅消息通道) */
@@ -959,7 +1005,7 @@ var TriggerCooldown = class {
959
1005
  if (!lanes) continue;
960
1006
  for (const [laneKey, state] of lanes) {
961
1007
  if (now < state.deadline) continue;
962
- const separator = laneKey.indexOf("\n");
1008
+ const separator = laneKey.indexOf(LANE_KEY_SEPARATOR);
963
1009
  lanes.delete(laneKey);
964
1010
  expired.push({
965
1011
  channelKey: key,
@@ -984,10 +1030,19 @@ var TriggerCooldown = class {
984
1030
 
985
1031
  // src/trigger/engine.ts
986
1032
  var SCHEDULER_TICK_MS = 1e3;
1033
+ var joinNonEmpty = /* @__PURE__ */ __name((parts) => parts.filter(Boolean).join("\n"), "joinNonEmpty");
987
1034
  function collectImageUrls(entries) {
988
1035
  return entries.flatMap((entry) => entry?.role === "user" ? entry.imageUrls ?? [] : []);
989
1036
  }
990
1037
  __name(collectImageUrls, "collectImageUrls");
1038
+ function windowFieldsOf(entries) {
1039
+ return {
1040
+ queryText: formatHistoryEntries(entries),
1041
+ contentText: formatHistoryBrief(entries),
1042
+ imageUrls: collectImageUrls(entries)
1043
+ };
1044
+ }
1045
+ __name(windowFieldsOf, "windowFieldsOf");
991
1046
  function sessionIdentityOf(session) {
992
1047
  return {
993
1048
  platform: session.platform,
@@ -1040,8 +1095,7 @@ var TriggerEngine = class {
1040
1095
  this.ctx.logger.info("已加载 %d 条启用规则", this.rules.length);
1041
1096
  }
1042
1097
  handleMessage(session, next) {
1043
- if (this.ctx.bots[session.uid]) return next();
1044
- if (!session.channelId) return next();
1098
+ if (this.ctx.bots[session.uid] || !session.channelId) return next();
1045
1099
  const channelKey = channelKeyOf(session.platform, session.channelId);
1046
1100
  this.tickCooldown(channelKey);
1047
1101
  const entry = buildHistoryEntry(session);
@@ -1112,16 +1166,14 @@ var TriggerEngine = class {
1112
1166
  ...sessionIdentityOf(session),
1113
1167
  triggerType: "density",
1114
1168
  rule: fire.rule,
1115
- queryText: formatHistoryEntries(fire.entries),
1116
- contentText: formatHistoryBrief(fire.entries),
1117
- imageUrls: collectImageUrls(fire.entries)
1169
+ ...windowFieldsOf(fire.entries)
1118
1170
  });
1119
1171
  }
1120
1172
  /** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
1121
1173
  fireMerged(rule, buffer) {
1122
1174
  const first = buffer[0];
1123
- const queryText = buffer.map((item) => item.entry ? formatHistoryEntry(item.entry) : "").filter(Boolean).join("\n");
1124
- const contentText = buffer.map((item) => item.contentText).filter(Boolean).join("\n");
1175
+ const queryText = joinNonEmpty(buffer.map((item) => item.entry ? formatHistoryEntry(item.entry) : ""));
1176
+ const contentText = joinNonEmpty(buffer.map((item) => item.contentText));
1125
1177
  this.beginCooldown(channelKeyOf(first.session.platform, first.session.channelId), rule.name, "message");
1126
1178
  this.run({
1127
1179
  session: first.session,
@@ -1135,10 +1187,9 @@ var TriggerEngine = class {
1135
1187
  });
1136
1188
  }
1137
1189
  /** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
1138
- fireDensityExpiry(rule, conditionIndex, channel, now) {
1190
+ fireDensityExpiry(rule, condition, conditionIndex, channel, now) {
1139
1191
  this.beginCooldown(channelKeyOf(channel.platform, channel.channelId), rule.name, densityLane(conditionIndex));
1140
- const condition = rule.conditions[conditionIndex];
1141
- const windowMs = condition.type === "density" ? condition.densityWindowSeconds * 1e3 : 0;
1192
+ const windowMs = condition.densityWindowSeconds * 1e3;
1142
1193
  let userId;
1143
1194
  for (let i = channel.entries.length - 1; i >= 0; i--) {
1144
1195
  const item = channel.entries[i];
@@ -1152,9 +1203,7 @@ var TriggerEngine = class {
1152
1203
  userId,
1153
1204
  triggerType: "density",
1154
1205
  rule,
1155
- queryText: formatHistoryEntries(channel.entries),
1156
- contentText: formatHistoryBrief(channel.entries),
1157
- imageUrls: collectImageUrls(channel.entries),
1206
+ ...windowFieldsOf(channel.entries),
1158
1207
  isDirect: channel.isDirect
1159
1208
  });
1160
1209
  }
@@ -1168,9 +1217,7 @@ var TriggerEngine = class {
1168
1217
  ...poolIdentityOf(channel),
1169
1218
  triggerType: "schedule",
1170
1219
  rule,
1171
- queryText: formatHistoryEntries(channel.entries),
1172
- contentText: formatHistoryBrief(channel.entries),
1173
- imageUrls: collectImageUrls(channel.entries),
1220
+ ...windowFieldsOf(channel.entries),
1174
1221
  isDirect: channel.isDirect
1175
1222
  });
1176
1223
  }
@@ -1194,16 +1241,10 @@ var TriggerEngine = class {
1194
1241
  const condition = rule.conditions[index];
1195
1242
  if (condition?.type !== "density") return;
1196
1243
  const channel = this.pool.takeDensityChannel(rule, condition, expired.channelKey, now);
1197
- if (channel) this.fireDensityExpiry(rule, index, channel, now);
1244
+ if (channel) this.fireDensityExpiry(rule, condition, index, channel, now);
1198
1245
  }
1199
1246
  };
1200
1247
 
1201
- // src/types.ts
1202
- function isMessageTrigger(type) {
1203
- return type !== "schedule" && type !== "density";
1204
- }
1205
- __name(isMessageTrigger, "isMessageTrigger");
1206
-
1207
1248
  // src/requester/files.ts
1208
1249
  var import_promises = require("fs/promises");
1209
1250
  var import_fs = require("fs");
@@ -1259,6 +1300,10 @@ function guessExtension(mimeType) {
1259
1300
  return found ? found[0] : "bin";
1260
1301
  }
1261
1302
  __name(guessExtension, "guessExtension");
1303
+ function fallbackFileName(mimeType) {
1304
+ return `file.${guessExtension(mimeType)}`;
1305
+ }
1306
+ __name(fallbackFileName, "fallbackFileName");
1262
1307
  function mimeToFileType(mimeType) {
1263
1308
  if (!mimeType) return "custom";
1264
1309
  if (mimeType.startsWith("image/")) return "image";
@@ -1272,15 +1317,11 @@ function mimeToFileType(mimeType) {
1272
1317
  __name(mimeToFileType, "mimeToFileType");
1273
1318
  function extractFileCandidates(session) {
1274
1319
  const candidates = [];
1275
- const seen = /* @__PURE__ */ new Set();
1276
1320
  for (const element of session.elements ?? []) {
1277
1321
  const difyType = ELEMENT_TYPE[element.type];
1278
1322
  if (!difyType) continue;
1279
- const source = element.attrs?.["url"] ?? element.attrs?.["src"];
1323
+ const source = imageSourceOf(element);
1280
1324
  if (typeof source !== "string" || !source) continue;
1281
- const key = `${difyType}:${source}`;
1282
- if (seen.has(key)) continue;
1283
- seen.add(key);
1284
1325
  candidates.push({ source, type: difyType });
1285
1326
  }
1286
1327
  return candidates;
@@ -1306,7 +1347,7 @@ function parseDataUrl(source) {
1306
1347
  const mimeType = match[1] || "application/octet-stream";
1307
1348
  const isBase64 = /(?:^|;)base64/.test(match[2] ?? "");
1308
1349
  const buffer = isBase64 ? Buffer.from(match[3], "base64") : percentDecodeBytes(match[3]);
1309
- return { buffer, fileName: `file.${guessExtension(mimeType)}`, mimeType };
1350
+ return { buffer, fileName: fallbackFileName(mimeType), mimeType };
1310
1351
  }
1311
1352
  __name(parseDataUrl, "parseDataUrl");
1312
1353
  async function readLocalFile(ctx, source) {
@@ -1317,7 +1358,7 @@ async function readLocalFile(ctx, source) {
1317
1358
  }
1318
1359
  const buffer = await (0, import_promises.readFile)(filePath);
1319
1360
  const mimeType = guessMime((0, import_path.extname)(filePath));
1320
- return { buffer, fileName: (0, import_path.basename)(filePath) || `file.${guessExtension(mimeType)}`, mimeType };
1361
+ return { buffer, fileName: (0, import_path.basename)(filePath) || fallbackFileName(mimeType), mimeType };
1321
1362
  }
1322
1363
  __name(readLocalFile, "readLocalFile");
1323
1364
  async function fetchRemoteFile(ctx, source) {
@@ -1333,7 +1374,7 @@ async function fetchRemoteFile(ctx, source) {
1333
1374
  } catch {
1334
1375
  }
1335
1376
  const mimeType = guessMime((0, import_path.extname)(fileName));
1336
- return { buffer, fileName: fileName || `file.${guessExtension(mimeType)}`, mimeType };
1377
+ return { buffer, fileName: fileName || fallbackFileName(mimeType), mimeType };
1337
1378
  } catch (error) {
1338
1379
  ctx.logger.warn("下载远程文件失败:%s (%s)", source, error);
1339
1380
  return null;
@@ -1448,29 +1489,73 @@ function resolveMetaField(key, fireCtx, now) {
1448
1489
  }
1449
1490
  }
1450
1491
  __name(resolveMetaField, "resolveMetaField");
1492
+ function historyVariableOf(fireCtx, history, limit) {
1493
+ const channelKey = channelKeyOf(fireCtx.platform, fireCtx.channelId);
1494
+ return history.toVariable(history.get(channelKey, limit ?? history.limit));
1495
+ }
1496
+ __name(historyVariableOf, "historyVariableOf");
1451
1497
  function resolveSource(source, fireCtx, history, now) {
1452
- switch (source?.type) {
1498
+ switch (source.type) {
1453
1499
  case "session":
1454
1500
  return resolveSessionField(source.key, fireCtx, now);
1455
1501
  case "meta":
1456
1502
  return resolveMetaField(source.key, fireCtx, now);
1457
1503
  case "constant":
1458
1504
  return source.value;
1459
- case "history": {
1460
- const channelKey = channelKeyOf(fireCtx.platform, fireCtx.channelId);
1461
- const limit = source.limit ?? history.limit;
1462
- return history.toVariable(history.get(channelKey, limit));
1463
- }
1505
+ case "history":
1506
+ return historyVariableOf(fireCtx, history, source.limit);
1464
1507
  default:
1465
1508
  return void 0;
1466
1509
  }
1467
1510
  }
1468
1511
  __name(resolveSource, "resolveSource");
1512
+ var META_PLACEHOLDERS = /* @__PURE__ */ new Set(["triggerType", "ruleName", "triggerTime"]);
1513
+ function resolvePlaceholderValue(name2, limit, fireCtx, history, now) {
1514
+ if (name2 === "history") {
1515
+ return historyVariableOf(fireCtx, history, limit);
1516
+ }
1517
+ if (name2 === "isDirect") {
1518
+ return resolveSessionField("isDirect", fireCtx, now);
1519
+ }
1520
+ if (META_PLACEHOLDERS.has(name2)) {
1521
+ return resolveMetaField(name2, fireCtx, now);
1522
+ }
1523
+ return String(resolveSessionField(name2, fireCtx, now));
1524
+ }
1525
+ __name(resolvePlaceholderValue, "resolvePlaceholderValue");
1526
+ var BARE_PLACEHOLDER = new RegExp(`"${JSON_PLACEHOLDER.source}"`, "g");
1527
+ var escapeJsonText = /* @__PURE__ */ __name((value) => JSON.stringify(value).slice(1, -1), "escapeJsonText");
1528
+ function interpolateTemplate(template, fireCtx, history, now) {
1529
+ const resolve = /* @__PURE__ */ __name((name2, limit) => resolvePlaceholderValue(name2, limit ? Number(limit) : void 0, fireCtx, history, now), "resolve");
1530
+ const bareReplaced = template.replace(
1531
+ BARE_PLACEHOLDER,
1532
+ (_match, name2, limit) => JSON.stringify(resolve(name2, limit))
1533
+ );
1534
+ return bareReplaced.replace(
1535
+ JSON_PLACEHOLDER,
1536
+ (_match, name2, limit) => escapeJsonText(String(resolve(name2, limit)))
1537
+ );
1538
+ }
1539
+ __name(interpolateTemplate, "interpolateTemplate");
1540
+ function resolveJsonObject(template, fireCtx, history, now) {
1541
+ try {
1542
+ const parsed = JSON.parse(interpolateTemplate(template, fireCtx, history, now));
1543
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
1544
+ } catch {
1545
+ return null;
1546
+ }
1547
+ }
1548
+ __name(resolveJsonObject, "resolveJsonObject");
1469
1549
  function resolveVariables(rule, fireCtx, history) {
1470
1550
  const inputs = {};
1471
1551
  const now = (/* @__PURE__ */ new Date()).toISOString();
1472
1552
  for (const binding of rule.variables) {
1473
- if (!binding?.field) continue;
1553
+ if (binding.source.type !== "json") continue;
1554
+ const object = resolveJsonObject(binding.source.template, fireCtx, history, now);
1555
+ if (object) Object.assign(inputs, object);
1556
+ }
1557
+ for (const binding of rule.variables) {
1558
+ if (binding.source.type === "json") continue;
1474
1559
  const value = resolveSource(binding.source, fireCtx, history, now);
1475
1560
  if (value !== void 0) inputs[binding.field] = value;
1476
1561
  }
@@ -1486,12 +1571,13 @@ function splitText(text, maxLength) {
1486
1571
  const lines = text.split("\n");
1487
1572
  const segments = [];
1488
1573
  let current = "";
1574
+ const flush = /* @__PURE__ */ __name(() => {
1575
+ if (current) segments.push(current);
1576
+ }, "flush");
1489
1577
  for (const line of lines) {
1490
1578
  if (line.length > maxLength) {
1491
- if (current) {
1492
- segments.push(current);
1493
- current = "";
1494
- }
1579
+ flush();
1580
+ current = "";
1495
1581
  for (let i = 0; i < line.length; i += maxLength) {
1496
1582
  segments.push(line.slice(i, i + maxLength));
1497
1583
  }
@@ -1505,7 +1591,7 @@ function splitText(text, maxLength) {
1505
1591
  ${line}` : line;
1506
1592
  }
1507
1593
  }
1508
- if (current) segments.push(current);
1594
+ flush();
1509
1595
  return segments;
1510
1596
  }
1511
1597
  __name(splitText, "splitText");
@@ -1609,22 +1695,7 @@ var RequestRunner = class {
1609
1695
  inputs: resolveVariables(rule, fireCtx, this.history),
1610
1696
  uploadedFiles: []
1611
1697
  };
1612
- const upload = resolveUploadConfig(this.config);
1613
- if (upload.enabled) {
1614
- const sessionCandidates = fireCtx.session ? extractFileCandidates(fireCtx.session) : [];
1615
- const urlCandidates = (fireCtx.imageUrls ?? []).filter((url) => !sessionCandidates.some((candidate) => candidate.source === url)).map((url) => ({ source: url, type: "image" }));
1616
- const { files, dropped } = await uploadCandidates(
1617
- this.ctx,
1618
- client,
1619
- [...sessionCandidates, ...urlCandidates],
1620
- requestCtx.difyUser,
1621
- { maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
1622
- );
1623
- requestCtx.uploadedFiles = files;
1624
- if (dropped.length) {
1625
- fireCtx.queryText = annotateDroppedPlaceholders(fireCtx.queryText, dropped);
1626
- }
1627
- }
1698
+ await this.uploadTriggerFiles(fireCtx, client, requestCtx);
1628
1699
  let reply;
1629
1700
  try {
1630
1701
  reply = await this.dispatch(app, client, fireCtx, requestCtx);
@@ -1648,6 +1719,30 @@ var RequestRunner = class {
1648
1719
  });
1649
1720
  await this.deliver(fireCtx, messages);
1650
1721
  }
1722
+ /**
1723
+ * 文件上传管线:消息触发取会话元素候选(含音视频文件),窗口类触发(合并/密度/定时)
1724
+ * 经 fireCtx.imageUrls 补齐相关消息的图片;同 URL 重复由上传入口去重;
1725
+ * 被丢弃图片在 query 的占位符上注明已丢弃,模型可感知缺图原因。
1726
+ * 注意:workflow 的「文件型输入变量」(inputs 内嵌文件对象)不在本管线范围内,
1727
+ * 顶层 files 已覆盖视觉模型组合输入的主场景。
1728
+ */
1729
+ async uploadTriggerFiles(fireCtx, client, requestCtx) {
1730
+ const upload = resolveUploadConfig(this.config);
1731
+ if (!upload.enabled) return;
1732
+ const sessionCandidates = fireCtx.session ? extractFileCandidates(fireCtx.session) : [];
1733
+ const urlCandidates = (fireCtx.imageUrls ?? []).map((url) => ({ source: url, type: "image" }));
1734
+ const { files, dropped } = await uploadCandidates(
1735
+ this.ctx,
1736
+ client,
1737
+ [...sessionCandidates, ...urlCandidates],
1738
+ requestCtx.difyUser,
1739
+ { maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
1740
+ );
1741
+ requestCtx.uploadedFiles = files;
1742
+ if (dropped.length) {
1743
+ fireCtx.queryText = annotateDroppedPlaceholders(fireCtx.queryText, dropped);
1744
+ }
1745
+ }
1651
1746
  /** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
1652
1747
  shouldQuoteReply(fireCtx, startedAt) {
1653
1748
  const quote = resolveQuoteConfig(this.config);
@@ -1683,12 +1778,16 @@ var RequestRunner = class {
1683
1778
  }
1684
1779
  return { text: response.answer, files: [] };
1685
1780
  }
1781
+ /** 逐条发送,单条失败仅告警不中断 */
1782
+ async sendAll(messages, send) {
1783
+ for (const message of messages) {
1784
+ await send(message).catch((error) => this.ctx.logger.warn(error));
1785
+ }
1786
+ }
1686
1787
  async deliver(fireCtx, messages) {
1687
1788
  if (!messages.length) return;
1688
1789
  if (fireCtx.session) {
1689
- for (const message of messages) {
1690
- await fireCtx.session.send(message).catch((error) => this.ctx.logger.warn(error));
1691
- }
1790
+ await this.sendAll(messages, (message) => fireCtx.session.send(message));
1692
1791
  return;
1693
1792
  }
1694
1793
  const bots = Object.values(this.ctx.bots);
@@ -1697,9 +1796,7 @@ var RequestRunner = class {
1697
1796
  this.ctx.logger.warn("平台 %s 上没有可用 bot,无法发送主动消息", fireCtx.platform);
1698
1797
  return;
1699
1798
  }
1700
- for (const message of messages) {
1701
- await bot.sendMessage(fireCtx.channelId, message).catch((error) => this.ctx.logger.warn(error));
1702
- }
1799
+ await this.sendAll(messages, (message) => bot.sendMessage(fireCtx.channelId, message));
1703
1800
  }
1704
1801
  };
1705
1802
 
@@ -1767,23 +1864,34 @@ var ConversationStore = class {
1767
1864
  async saveConversationId(bindingKey, difyConversationId) {
1768
1865
  await this.ctx.database.upsert("dify_conversation", [{ bindingKey, difyConversationId }]);
1769
1866
  }
1770
- /** 解析 bindingKey 的构成(重置时反查应用凭据与 difyUser 用) */
1867
+ /** 解析 bindingKey 的构成(重置时反查应用凭据与 difyUser 用)。
1868
+ * 段位定位假设 appId / platform / selfId 不含冒号(平台名与应用标识按约定不含;
1869
+ * Matrix 的 bot id 含冒号属键格式改造范畴,当前不区分) */
1771
1870
  parseBindingKey(bindingKey) {
1772
- const [appId, platform, , scope, ...ids] = bindingKey.split(":");
1871
+ const segments = bindingKey.split(":");
1872
+ if (segments.length === 2 && segments[1] === "global") {
1873
+ return { appId: segments[0], platform: "", scope: "global", ids: [] };
1874
+ }
1875
+ const [appId, platform, , scope, ...ids] = segments;
1773
1876
  return { appId, platform, scope, ids };
1774
1877
  }
1878
+ /** scope 之后的 id 片段拼回整体(键内 id 可能内嵌冒号,消费方按语义整体比较) */
1879
+ joinedIdsOf(bindingKey) {
1880
+ return this.parseBindingKey(bindingKey).ids.join(":");
1881
+ }
1775
1882
  /** 由 bindingKey 还原删除 Dify 会话所需的 difyUser(与 computeDifyUser 对齐) */
1776
1883
  difyUserOfBindingKey(bindingKey) {
1777
- const { platform, scope, ids } = this.parseBindingKey(bindingKey);
1884
+ const { platform, scope } = this.parseBindingKey(bindingKey);
1885
+ const ids = this.joinedIdsOf(bindingKey);
1778
1886
  switch (scope) {
1779
1887
  case "global":
1780
1888
  return "koishi";
1781
1889
  case "private":
1782
- return `koishi:${platform}:private:${ids.join(":")}`;
1890
+ return `koishi:${platform}:private:${ids}`;
1783
1891
  case "guild":
1784
1892
  case "channel":
1785
1893
  case "user":
1786
- return `koishi:${platform}:${ids.join(":")}`;
1894
+ return `koishi:${platform}:${ids}`;
1787
1895
  default:
1788
1896
  return `koishi:${platform}`;
1789
1897
  }
@@ -1804,12 +1912,13 @@ var ConversationStore = class {
1804
1912
  const [channelPlatform, channelId] = splitKey(filter.channelKey ?? "");
1805
1913
  const matched = rows.filter((row) => {
1806
1914
  if (filter.bindingKey) return row.bindingKey === filter.bindingKey;
1807
- const { platform, scope, ids } = this.parseBindingKey(row.bindingKey);
1915
+ const { platform, scope } = this.parseBindingKey(row.bindingKey);
1808
1916
  if (platform !== guildPlatform) return false;
1809
- if (scope === "guild") return ids[0] === guildId;
1810
- if (scope === "user") return ids.length === 2 ? ids[0] === guildId : false;
1811
- if (scope === "channel") return platform === channelPlatform && ids[0] === channelId;
1812
- if (scope === "private") return filter.isDirect === true && ids[0] === filter.userId;
1917
+ const joinedIds = this.joinedIdsOf(row.bindingKey);
1918
+ if (scope === "guild") return joinedIds === guildId;
1919
+ if (scope === "user") return joinedIds.startsWith(`${guildId}:`);
1920
+ if (scope === "channel") return platform === channelPlatform && joinedIds === channelId;
1921
+ if (scope === "private") return filter.isDirect === true && joinedIds === filter.userId;
1813
1922
  return false;
1814
1923
  });
1815
1924
  let reset = 0;
@@ -1852,14 +1961,14 @@ __name(defineTables, "defineTables");
1852
1961
  // src/commands/misc.ts
1853
1962
  function registerMiscCommands(parent, conversationStore) {
1854
1963
  parent.subcommand(".conv.reset", { authority: 3 }).action(async ({ session }) => {
1855
- const reset = await conversationStore.resetWhere({
1964
+ const resetCount = await conversationStore.resetWhere({
1856
1965
  guildKey: guildKeyOf(session.platform, session.guildId, session.channelId),
1857
1966
  channelKey: channelKeyOf(session.platform, session.channelId),
1858
1967
  isDirect: session.isDirect,
1859
1968
  userId: session.userId
1860
1969
  });
1861
- if (!reset) return session.text("dify-ai.commands.conv-empty");
1862
- return session.text("dify-ai.commands.conv-reset", [reset]);
1970
+ if (!resetCount) return session.text("dify-ai.commands.conv-empty");
1971
+ return session.text("dify-ai.commands.conv-reset", [resetCount]);
1863
1972
  });
1864
1973
  }
1865
1974
  __name(registerMiscCommands, "registerMiscCommands");
@@ -1869,6 +1978,7 @@ var name = "dify-ai";
1869
1978
  var inject = { required: ["database"] };
1870
1979
  var PROBE_TIMEOUT_MS = 1e4;
1871
1980
  function apply(ctx, config) {
1981
+ const startedAt = Date.now();
1872
1982
  ctx.i18n.define("zh-CN", zh_CN_default);
1873
1983
  ctx.i18n.define("en-US", en_US_default);
1874
1984
  defineTables(ctx);
@@ -1880,7 +1990,11 @@ function apply(ctx, config) {
1880
1990
  const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
1881
1991
  ctx.on("ready", () => {
1882
1992
  engine.start();
1883
- void probeAppTypes(ctx, apps, appTypes);
1993
+ ctx.logger.info("插件装配完成,耗时 %dms", Date.now() - startedAt);
1994
+ const probeStart = Date.now();
1995
+ void probeAppTypes(ctx, apps, appTypes).then(() => {
1996
+ ctx.logger.info("应用类型探测完成,耗时 %dms", Date.now() - probeStart);
1997
+ });
1884
1998
  });
1885
1999
  const admin = ctx.command("dify", { authority: 3 });
1886
2000
  registerMiscCommands(admin, conversationStore);
@@ -13,7 +13,7 @@ interface FilePayload {
13
13
  }
14
14
  /** mime → Dify 文件类型 */
15
15
  export declare function mimeToFileType(mimeType?: string): DifyFileType;
16
- /** 从消息元素中提取可上传的文件候选(图片/语音/视频/附件),按来源去重 */
16
+ /** 从消息元素中提取可上传的文件候选(图片/语音/视频/附件);同源去重由上传入口统一处理 */
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>;
@@ -27,6 +27,6 @@ export interface UploadResult {
27
27
  files: DifyInputFileObject[];
28
28
  dropped: string[];
29
29
  }
30
- /** 上传一批候选(入口按 type:source 去重,再按数量上限截断、逐个上传) */
30
+ /** 上传一批候选(入口按来源 URL 去重,再按数量上限截断、逐个上传) */
31
31
  export declare function uploadCandidates(ctx: Context, client: DifyClient, candidates: FileCandidate[], difyUser: string, limit: UploadLimit): Promise<UploadResult>;
32
32
  export {};
@@ -28,10 +28,20 @@ export declare class RequestRunner {
28
28
  /** 应用类型:schema 显式指定优先,auto 时用探测结果(未探测到回退 chat) */
29
29
  private appTypeOf;
30
30
  run(fireCtx: FireContext): Promise<void>;
31
+ /**
32
+ * 文件上传管线:消息触发取会话元素候选(含音视频文件),窗口类触发(合并/密度/定时)
33
+ * 经 fireCtx.imageUrls 补齐相关消息的图片;同 URL 重复由上传入口去重;
34
+ * 被丢弃图片在 query 的占位符上注明已丢弃,模型可感知缺图原因。
35
+ * 注意:workflow 的「文件型输入变量」(inputs 内嵌文件对象)不在本管线范围内,
36
+ * 顶层 files 已覆盖视觉模型组合输入的主场景。
37
+ */
38
+ private uploadTriggerFiles;
31
39
  /** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
32
40
  private shouldQuoteReply;
33
41
  /** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
34
42
  private dispatch;
43
+ /** 逐条发送,单条失败仅告警不中断 */
44
+ private sendAll;
35
45
  private deliver;
36
46
  }
37
47
  export {};
@@ -1,5 +1,9 @@
1
1
  import { FireContext } from '../types';
2
2
  import { MessageHistory } from '../history';
3
3
  import { DifyRule } from '../types';
4
- /** 按规则的变量映射表构造 Dify inputs */
4
+ /**
5
+ * 按规则的变量映射表构造 Dify inputs。
6
+ * JSON 对象条目先注入(顶层键展开合并进 inputs),单条映射后注入覆盖同名键——
7
+ * 现有单条配置行为不变,JSON 作为批量默认值。
8
+ */
5
9
  export declare function resolveVariables(rule: DifyRule, fireCtx: FireContext, history: MessageHistory): Record<string, any>;
@@ -27,8 +27,12 @@ export declare class ConversationStore {
27
27
  computeDifyUser(rule: DifyRule, fireCtx: FireContext): string;
28
28
  getConversationId(bindingKey: string): Promise<string | undefined>;
29
29
  saveConversationId(bindingKey: string, difyConversationId: string): Promise<void>;
30
- /** 解析 bindingKey 的构成(重置时反查应用凭据与 difyUser 用) */
30
+ /** 解析 bindingKey 的构成(重置时反查应用凭据与 difyUser 用)。
31
+ * 段位定位假设 appId / platform / selfId 不含冒号(平台名与应用标识按约定不含;
32
+ * Matrix 的 bot id 含冒号属键格式改造范畴,当前不区分) */
31
33
  private parseBindingKey;
34
+ /** scope 之后的 id 片段拼回整体(键内 id 可能内嵌冒号,消费方按语义整体比较) */
35
+ private joinedIdsOf;
32
36
  /** 由 bindingKey 还原删除 Dify 会话所需的 difyUser(与 computeDifyUser 对齐) */
33
37
  private difyUserOfBindingKey;
34
38
  /**
@@ -38,9 +38,11 @@ export declare class TriggerCooldown {
38
38
  isCooling(channelKey: string, ruleName: string, lane: CooldownLane, now: number): boolean;
39
39
  /** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
40
40
  private slide;
41
- /** 滑动窗口顺延:已到期的通道不复活 */
41
+ /** 滑动窗口顺延:sliding 模式且通道未到期才执行(已到期的不复活) */
42
+ private slideIfActive;
43
+ /** 滑动窗口顺延 */
42
44
  extend(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
43
- /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
45
+ /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
44
46
  extendDensityLanes(channelKey: string, now: number): void;
45
47
  /** 冷却期内缓存一条命中消息(仅消息通道) */
46
48
  bufferMessage(channelKey: string, ruleName: string, message: PendingMessage): void;
@@ -13,8 +13,7 @@ export interface ChannelPool {
13
13
  }
14
14
  export interface DensityFire {
15
15
  rule: DifyRule;
16
- /** 命中的密度条件与其在规则内的下标(密度冷却通道键) */
17
- condition: DensityCondition;
16
+ /** 命中条件在规则内的下标(密度冷却通道键) */
18
17
  conditionIndex: number;
19
18
  /** 触发时取走的该频道全池消息(含 bot 行与时效外条目) */
20
19
  entries: HistoryEntry[];
package/lib/types.d.ts CHANGED
@@ -68,7 +68,12 @@ export type VariableSource = {
68
68
  } | {
69
69
  type: 'constant';
70
70
  value: string;
71
+ } | {
72
+ type: 'json';
73
+ template: string;
71
74
  };
75
+ /** JSON 对象模板里的占位符:{{变量名}} 或 {{history:条数}}(加载校验与运行时插值共用) */
76
+ export declare const JSON_PLACEHOLDER: RegExp;
72
77
  /** 一条变量映射:Dify inputs 字段名 ← 来源 */
73
78
  export interface VariableBinding {
74
79
  field: string;
@@ -1,4 +1,4 @@
1
- import { Session } from 'koishi';
1
+ import { h, Session } from 'koishi';
2
2
  /** 图片占位符携带的 URL 摘要长度(hex 位) */
3
3
  export declare const IMAGE_HASH_LENGTH = 8;
4
4
  /**
@@ -20,6 +20,8 @@ interface ExtractVerbatimOptions {
20
20
  /** 跳过 @ 机器人自身的渲染(content 变量语义:@bot 是触发结构而非消息内容) */
21
21
  excludeBotAt?: boolean;
22
22
  }
23
+ /** 媒体元素的源地址(url 或 src 属性,未定形态原样返回由调用方守卫) */
24
+ export declare function imageSourceOf(element: h): unknown;
23
25
  /**
24
26
  * 消息原貌文本:text 元素按序拼接,@ 元素渲染为 @名字(缺名字用 ID),
25
27
  * img/image 元素渲染为 [image:URL摘要]——统一格式行(query/池/历史)用默认形态,
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.7",
4
+ "version": "0.1.10",
5
5
  "author": "PinkElysia",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/PinkElysiaDev/dify-ai-plugin-for-Koishi#readme",