koishi-plugin-dify-ai 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 {
@@ -111,8 +114,8 @@ export interface Config {
111
114
  }
112
115
  export declare const Config: Schema<Config>;
113
116
  /**
114
- * 上传消息文件配置(含 0.1.x 布尔遗留形态迁移):
115
- * 数量与单文件大小 0 均为不限,大小仍与 Dify 各类型上限取较小值。
117
+ * 上传消息文件配置(含历史形态迁移:0.1.x 布尔、0.1.7 mode)。
118
+ * 数量与单文件大小 0 与 -1 均为不限,大小仍与 Dify 各类型上限取较小值。
116
119
  */
117
120
  export declare function resolveUploadConfig(config: Config): {
118
121
  enabled: boolean;
@@ -120,9 +123,9 @@ export declare function resolveUploadConfig(config: Config): {
120
123
  maxMb: number;
121
124
  };
122
125
  /**
123
- * 引用触发消息配置(含 0.1.x 布尔开关迁移):schema 会给旧配置填默认 quote 对象,
124
- * 因此迁移判据是「旧布尔被配置层透传保留 且 quote 仍是默认形态(用户未在新表单改过)」——
125
- * 此时以旧布尔为准;用户改过新表单(quote 非默认)则新值优先。
126
+ * 引用触发消息配置(含历史形态迁移:0.1.x 布尔与 quoteTrigger 透传、0.1.7 mode、布尔开关新形态)。
127
+ * 迁移判据沿用:旧布尔被透传保留且配置未被用户改动(默认形态)时以旧布尔为准。
128
+ * 阈值 -1 与 0 同为总是引用(比较 elapsed >= 阈值 自然兼容)。
126
129
  */
127
130
  export declare function resolveQuoteConfig(config: Config): {
128
131
  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,17 +141,32 @@ 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
150
  var quoteSchema = import_koishi.Schema.union([
138
- import_koishi.Schema.object({
139
- mode: import_koishi.Schema.const("off").required()
140
- }).default({ mode: "off" }).description("关闭"),
151
+ import_koishi.Schema.intersect([
152
+ import_koishi.Schema.object({
153
+ enabled: import_koishi.Schema.boolean().default(true).description("开启")
154
+ }),
155
+ import_koishi.Schema.union([
156
+ import_koishi.Schema.object({
157
+ enabled: import_koishi.Schema.const(true).required(),
158
+ minElapsedSeconds: import_koishi.Schema.number().min(-1).default(-1).description("回复耗时多少秒才引用,0 总是引用")
159
+ }),
160
+ import_koishi.Schema.object({})
161
+ ])
162
+ ]),
163
+ import_koishi.Schema.boolean(),
164
+ import_koishi.Schema.object({ mode: import_koishi.Schema.const("off").required() }),
141
165
  import_koishi.Schema.object({
142
166
  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
- ]);
167
+ minElapsedSeconds: import_koishi.Schema.number().min(-1).default(-1)
168
+ })
169
+ ]).default({ enabled: true, minElapsedSeconds: -1 });
146
170
  var cooldownSchema = import_koishi.Schema.union([
147
171
  import_koishi.Schema.object({
148
172
  mode: import_koishi.Schema.const("off").required()
@@ -158,15 +182,27 @@ var cooldownSchema = import_koishi.Schema.union([
158
182
  }).default({ mode: "sliding", minSeconds: 30, maxSeconds: 120 }).description("滑动窗口")
159
183
  ]);
160
184
  var uploadSchema = import_koishi.Schema.union([
161
- import_koishi.Schema.object({
162
- mode: import_koishi.Schema.const("off").required()
163
- }).default({ mode: "off" }).description("关闭"),
185
+ import_koishi.Schema.intersect([
186
+ import_koishi.Schema.object({
187
+ enabled: import_koishi.Schema.boolean().default(true).description("开启")
188
+ }),
189
+ import_koishi.Schema.union([
190
+ import_koishi.Schema.object({
191
+ enabled: import_koishi.Schema.const(true).required(),
192
+ maxCount: import_koishi.Schema.number().min(-1).default(-1).description("数量上限,0 不限"),
193
+ maxSizeMb: import_koishi.Schema.number().min(-1).default(-1).description("单文件大小上限 MB,0 不限")
194
+ }),
195
+ import_koishi.Schema.object({})
196
+ ])
197
+ ]),
198
+ import_koishi.Schema.boolean(),
199
+ import_koishi.Schema.object({ mode: import_koishi.Schema.const("off").required() }),
164
200
  import_koishi.Schema.object({
165
201
  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
- ]);
202
+ maxCount: import_koishi.Schema.number().min(-1).default(-1),
203
+ maxSizeMb: import_koishi.Schema.number().min(-1).default(-1)
204
+ })
205
+ ]).default({ enabled: true, maxCount: -1, maxSizeMb: -1 });
170
206
  var Config = import_koishi.Schema.intersect([
171
207
  import_koishi.Schema.object({
172
208
  apps: import_koishi.Schema.array(
@@ -184,7 +220,7 @@ var Config = import_koishi.Schema.intersect([
184
220
  import_koishi.Schema.object({
185
221
  name: import_koishi.Schema.string().required().description("规则名"),
186
222
  enabled: import_koishi.Schema.boolean().default(true).description("启用"),
187
- priority: import_koishi.Schema.number().default(0).description("优先级,大者先匹配"),
223
+ priority: import_koishi.Schema.number().default(-1).description("优先级,大者先匹配"),
188
224
  app: import_koishi.Schema.string().required().description("应用名"),
189
225
  conversationScope: import_koishi.Schema.union([
190
226
  import_koishi.Schema.const("per-guild").description("按群聊"),
@@ -213,9 +249,9 @@ var Config = import_koishi.Schema.intersect([
213
249
  }).description("触发规则"),
214
250
  import_koishi.Schema.object({
215
251
  parseElements: import_koishi.Schema.boolean().default(false).description("解析回复中的消息元素"),
216
- quote: quoteSchema.default({ mode: "on", minElapsedSeconds: 0 }).description("引用触发消息"),
217
- maxSegmentLength: import_koishi.Schema.natural().default(0).description("分段长度,0 不分段"),
218
- uploadFiles: uploadSchema.default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("上传消息中的文件")
252
+ quote: quoteSchema.description("引用触发消息"),
253
+ maxSegmentLength: import_koishi.Schema.number().min(-1).default(-1).description("分段长度,0 不分段"),
254
+ uploadFiles: uploadSchema.description("上传消息中的文件")
219
255
  }).description("回复行为"),
220
256
  import_koishi.Schema.object({
221
257
  requestTimeout: import_koishi.Schema.natural().role("ms").default(3e5).description("请求超时"),
@@ -308,6 +344,17 @@ function toScope(rows) {
308
344
  }
309
345
  __name(toScope, "toScope");
310
346
  function toVariable(entry) {
347
+ if (entry.source === "json") {
348
+ const template = entry.template ?? "";
349
+ if (!template.trim()) return null;
350
+ try {
351
+ const probe = JSON.parse(template.replace(JSON_PLACEHOLDER, "0"));
352
+ if (typeof probe !== "object" || probe === null || Array.isArray(probe)) return null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ return { field: "", source: { type: "json", template } };
357
+ }
311
358
  const field = entry.field?.trim();
312
359
  if (!field) return null;
313
360
  if (entry.source === "constant") {
@@ -316,51 +363,51 @@ function toVariable(entry) {
316
363
  }
317
364
  if (entry.source === "meta") {
318
365
  if (entry.key === "none") return null;
319
- return { field, source: { type: "meta", key: entry.key || "triggerType" } };
366
+ return { field, source: { type: "meta", key: entry.key } };
320
367
  }
321
368
  if (entry.source === "history") {
322
369
  const limit = Number(entry.limit);
323
370
  return { field, source: { type: "history", ...limit > 0 ? { limit } : {} } };
324
371
  }
325
372
  if (entry.key === "none" || entry.key === "history") return null;
326
- return { field, source: { type: "session", key: entry.key || "content" } };
373
+ return { field, source: { type: "session", key: entry.key } };
327
374
  }
328
375
  __name(toVariable, "toVariable");
329
376
  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 };
377
+ const upload = config.uploadFiles;
378
+ if (typeof upload === "boolean") {
379
+ return upload ? { enabled: true, maxCount: 0, maxMb: 0 } : { enabled: false, maxCount: 0, maxMb: 0 };
380
+ }
381
+ if (upload?.mode === "off" || upload?.enabled === false) {
382
+ return { enabled: false, maxCount: 0, maxMb: 0 };
332
383
  }
333
- if (config.uploadFiles?.mode === "off") return { enabled: false, maxCount: 0, maxMb: 0 };
334
384
  return {
335
385
  enabled: true,
336
- maxCount: Math.max(0, Number(config.uploadFiles?.maxCount) || 0),
337
- maxMb: Math.max(0, Number(config.uploadFiles?.maxSizeMb) || 0)
386
+ maxCount: Number(upload?.maxCount) || 0,
387
+ maxMb: Number(upload?.maxSizeMb) || 0
338
388
  };
339
389
  }
340
390
  __name(resolveUploadConfig, "resolveUploadConfig");
341
391
  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 };
392
+ const quote = config.quote;
393
+ if (typeof quote === "boolean") return { enabled: quote, minElapsedSeconds: 0 };
394
+ if (quote?.mode === "off") return { enabled: false, minElapsedSeconds: 0 };
395
+ const threshold = Number(quote?.minElapsedSeconds) || 0;
396
+ const untouched = !(threshold > 0) && quote?.enabled !== false;
397
+ if (typeof config.quoteTrigger === "boolean" && untouched) {
398
+ return { enabled: config.quoteTrigger, minElapsedSeconds: 0 };
399
+ }
400
+ return { enabled: quote?.mode === "on" ? true : quote?.enabled !== false, minElapsedSeconds: threshold };
348
401
  }
349
402
  __name(resolveQuoteConfig, "resolveQuoteConfig");
350
403
  function toCooldown(entry) {
351
404
  if (entry?.mode === "fixed") {
352
- const seconds = Number(entry.seconds);
353
- return seconds > 0 ? { mode: "fixed", ms: seconds * 1e3 } : null;
405
+ return { mode: "fixed", ms: Number(entry.seconds) * 1e3 };
354
406
  }
355
407
  if (entry?.mode === "sliding") {
356
408
  const minSeconds = Number(entry.minSeconds);
357
409
  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
- };
410
+ return { mode: "sliding", minMs: minSeconds * 1e3, maxMs: Math.max(minSeconds, maxSeconds) * 1e3 };
364
411
  }
365
412
  return null;
366
413
  }
@@ -371,12 +418,16 @@ function normalizeConfig(config) {
371
418
  if (app.name?.trim() && !apps.has(app.name)) apps.set(app.name, app);
372
419
  }
373
420
  const rules = [];
421
+ const seenNames = /* @__PURE__ */ new Set();
374
422
  for (const entry of config.rules ?? []) {
375
423
  if (!entry.name?.trim() || entry.enabled === false) continue;
424
+ const name2 = entry.name.trim();
425
+ if (seenNames.has(name2)) continue;
426
+ seenNames.add(name2);
376
427
  const conditions = (entry.conditions ?? []).filter((condition) => condition != null).map(toCondition).filter((condition) => condition != null);
377
428
  const variables = (entry.variables ?? []).filter((variable) => variable != null).map(toVariable).filter((variable) => variable != null);
378
429
  rules.push({
379
- name: entry.name.trim(),
430
+ name: name2,
380
431
  enabled: true,
381
432
  priority: Number(entry.priority) || 0,
382
433
  app: entry.app?.trim() ?? "",
@@ -449,9 +500,6 @@ var DifyClient = class {
449
500
  getAppInfo() {
450
501
  return this.request("GET", "/info");
451
502
  }
452
- getParameters() {
453
- return this.request("GET", "/parameters");
454
- }
455
503
  /** chat / agent-chat / advanced-chat(conversation_id 传空串表示新建会话) */
456
504
  runChat(body) {
457
505
  return this.request("POST", "/chat-messages", body);
@@ -500,6 +548,7 @@ __name(createDifyClient, "createDifyClient");
500
548
  // src/utils/session-text.ts
501
549
  var import_crypto = require("crypto");
502
550
  var import_koishi2 = require("koishi");
551
+ var IMAGE_HASH_LENGTH = 8;
503
552
  function parseTextSafe(content) {
504
553
  if (!content) return "";
505
554
  try {
@@ -519,7 +568,7 @@ __name(extractText, "extractText");
519
568
  var IMAGE_PLACEHOLDER = "[图片]";
520
569
  function imagePlaceholder(url) {
521
570
  if (!url) return IMAGE_PLACEHOLDER;
522
- return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0, 8)}]`;
571
+ return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0, IMAGE_HASH_LENGTH)}]`;
523
572
  }
524
573
  __name(imagePlaceholder, "imagePlaceholder");
525
574
  function annotateDroppedPlaceholders(text, droppedUrls) {
@@ -531,20 +580,23 @@ function annotateDroppedPlaceholders(text, droppedUrls) {
531
580
  return result;
532
581
  }
533
582
  __name(annotateDroppedPlaceholders, "annotateDroppedPlaceholders");
534
- function extractTextWithPlaceholders(session) {
535
- const elements = session.elements?.length ? session.elements : (() => {
536
- try {
537
- return import_koishi2.h.parse(session.content ?? "");
538
- } catch {
539
- return [];
540
- }
541
- })();
583
+ function elementsOf(session) {
584
+ if (session.elements?.length) return session.elements;
585
+ try {
586
+ return import_koishi2.h.parse(session.content ?? "");
587
+ } catch {
588
+ return [];
589
+ }
590
+ }
591
+ __name(elementsOf, "elementsOf");
592
+ function extractTextWithPlaceholders(session, options = {}) {
542
593
  const parts = [];
543
- for (const element of elements) {
594
+ for (const element of elementsOf(session)) {
544
595
  if (element.type === "text") {
545
596
  const text = element.attrs?.["content"] ?? "";
546
597
  if (text) parts.push(text);
547
598
  } else if (element.type === "at") {
599
+ if (options.excludeBotAt && element.attrs?.["id"] === session.selfId) continue;
548
600
  const name2 = element.attrs?.["name"] ?? element.attrs?.["id"];
549
601
  if (name2) parts.push(`@${name2}`);
550
602
  } else if (element.type === "img" || element.type === "image") {
@@ -554,6 +606,16 @@ function extractTextWithPlaceholders(session) {
554
606
  return parts.join(" ").replace(/\s+/g, " ").trim();
555
607
  }
556
608
  __name(extractTextWithPlaceholders, "extractTextWithPlaceholders");
609
+ function extractImageUrls(session) {
610
+ const urls = [];
611
+ for (const element of elementsOf(session)) {
612
+ if (element.type !== "img" && element.type !== "image") continue;
613
+ const source = element.attrs?.["url"] ?? element.attrs?.["src"];
614
+ if (typeof source === "string" && source) urls.push(source);
615
+ }
616
+ return urls;
617
+ }
618
+ __name(extractImageUrls, "extractImageUrls");
557
619
 
558
620
  // src/history.ts
559
621
  var HISTORY_CAPACITY_MULTIPLIER = 2;
@@ -575,7 +637,7 @@ function formatHistoryEntries(entries) {
575
637
  }
576
638
  __name(formatHistoryEntries, "formatHistoryEntries");
577
639
  function formatHistoryBrief(entries) {
578
- return entries.map((entry) => entry.messageId ? `[msg:${entry.messageId}] ${entry.content}` : entry.content).join("\n");
640
+ return entries.map((entry) => entry.messageId ? `[msg:${entry.messageId}] ${entry.contentText}` : entry.contentText).join("\n");
579
641
  }
580
642
  __name(formatHistoryBrief, "formatHistoryBrief");
581
643
  function buildQuote(session) {
@@ -592,12 +654,15 @@ __name(buildQuote, "buildQuote");
592
654
  function buildHistoryEntry(session, role = "user") {
593
655
  const content = extractTextWithPlaceholders(session);
594
656
  if (!content) return null;
657
+ const imageUrls = extractImageUrls(session);
595
658
  return {
596
659
  role,
597
660
  userId: role === "assistant" ? session.selfId : session.userId ?? "未知用户",
598
- username: role === "assistant" ? "你自己的发言" : session.username || session.userId || "用户",
661
+ username: role === "assistant" ? "你自己的发言" : session.username || session.userId,
599
662
  messageId: session.messageId,
600
663
  content,
664
+ contentText: extractTextWithPlaceholders(session, { excludeBotAt: true }),
665
+ ...imageUrls.length ? { imageUrls } : {},
601
666
  timestamp: session.timestamp ?? Date.now(),
602
667
  quote: role === "user" ? buildQuote(session) : void 0
603
668
  };
@@ -635,41 +700,49 @@ function hasAt(session, id) {
635
700
  return session.elements?.some((element) => element.type === "at" && element.attrs?.["id"] === id) ?? false;
636
701
  }
637
702
  __name(hasAt, "hasAt");
703
+ var NON_TEXT_PREFIX = new RegExp(`^(?:@\\S+|\\[image:[0-9a-f]{${IMAGE_HASH_LENGTH}}\\]|\\[图片\\]|\\s)*`);
704
+ function stripPrefixKeyword(verbatim, keyword) {
705
+ const prefix = NON_TEXT_PREFIX.exec(verbatim)?.[0] ?? "";
706
+ const rest = verbatim.slice(prefix.length);
707
+ if (!rest.startsWith(keyword)) return null;
708
+ return (prefix + rest.slice(keyword.length)).trim();
709
+ }
710
+ __name(stripPrefixKeyword, "stripPrefixKeyword");
638
711
  function evaluateCondition(condition, session) {
639
712
  const text = extractText(session);
640
- const verbatim = extractTextWithPlaceholders(session);
713
+ const verbatim = extractTextWithPlaceholders(session, { excludeBotAt: true });
641
714
  switch (condition.type) {
642
715
  case "keyword-prefix": {
643
716
  const keyword = condition.keywords.find((k) => k && text.startsWith(k));
644
717
  if (!keyword) return null;
645
- const rest = text.slice(keyword.length).trim();
646
- return { type: "keyword-prefix", queryText: rest || verbatim };
718
+ const rest = verbatim.startsWith(keyword) ? verbatim.slice(keyword.length).trim() : stripPrefixKeyword(verbatim, keyword) ?? text.slice(keyword.length).trim();
719
+ return { type: "keyword-prefix", contentText: rest || verbatim };
647
720
  }
648
721
  case "keyword-contains": {
649
722
  const keyword = condition.keywords.find((k) => k && text.includes(k));
650
723
  if (!keyword) return null;
651
- return { type: "keyword-contains", queryText: verbatim };
724
+ return { type: "keyword-contains", contentText: verbatim };
652
725
  }
653
726
  case "at-bot": {
654
727
  const botId = session.bot?.userId;
655
728
  if (!botId) return null;
656
729
  if (!session.stripped?.atSelf && !hasAt(session, botId)) return null;
657
- return { type: "at-bot", queryText: verbatim };
730
+ return { type: "at-bot", contentText: verbatim };
658
731
  }
659
732
  case "at-user": {
660
733
  const isAtAnyUser = condition.userIds.some((id) => hasAt(session, id));
661
734
  if (!isAtAnyUser) return null;
662
- return { type: "at-user", queryText: verbatim };
735
+ return { type: "at-user", contentText: verbatim };
663
736
  }
664
737
  case "quote-bot": {
665
738
  const botId = session.bot?.userId;
666
739
  if (!botId || session.quote?.user?.id !== botId) return null;
667
- return { type: "quote-bot", queryText: verbatim };
740
+ return { type: "quote-bot", contentText: verbatim };
668
741
  }
669
742
  case "quote-user": {
670
743
  const author = session.quote?.user?.id;
671
744
  if (!author || !condition.userIds.includes(author)) return null;
672
- return { type: "quote-user", queryText: verbatim };
745
+ return { type: "quote-user", contentText: verbatim };
673
746
  }
674
747
  default:
675
748
  return null;
@@ -808,13 +881,7 @@ var RuleScheduler = class {
808
881
  }
809
882
  entries = [];
810
883
  sync(rules) {
811
- const previous = new Map(this.entries.map((entry) => [entry.rule.name, entry.nextRunAt]));
812
- const nextEntries = [];
813
- for (const rule of rules) {
814
- const nextRunAt = previous.get(rule.name) ?? this.computeNextRun(rule);
815
- if (nextRunAt != null) nextEntries.push({ rule, nextRunAt });
816
- }
817
- this.entries = nextEntries;
884
+ this.entries = rules.map((rule) => ({ rule, nextRunAt: this.computeNextRun(rule) })).filter((entry) => entry.nextRunAt != null);
818
885
  }
819
886
  tick() {
820
887
  const now = Date.now();
@@ -864,8 +931,9 @@ var RuleScheduler = class {
864
931
 
865
932
  // src/trigger/cooldown.ts
866
933
  var BUFFER_CAPACITY = 1024;
934
+ var DENSITY_LANE_PREFIX = "density:";
867
935
  function densityLane(index) {
868
- return `density:${index}`;
936
+ return `${DENSITY_LANE_PREFIX}${index}`;
869
937
  }
870
938
  __name(densityLane, "densityLane");
871
939
  function laneKeyOf(ruleName, lane) {
@@ -896,30 +964,33 @@ var TriggerCooldown = class {
896
964
  });
897
965
  }
898
966
  isCooling(channelKey, ruleName, lane, now) {
899
- const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
967
+ const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
900
968
  return state != null && now < state.deadline;
901
969
  }
902
- /** 滑动窗口顺延:截止时间顺延到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
903
- extend(channelKey, ruleName, lane, now) {
904
- if (this.setting?.mode !== "sliding") return;
905
- const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
906
- if (!state) return;
970
+ /** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
971
+ slide(state, setting, now) {
907
972
  state.deadline = Math.min(
908
- Math.max(state.deadline, now + this.setting.minMs),
909
- state.fireAt + this.setting.maxMs
973
+ Math.max(state.deadline, now + setting.minMs),
974
+ state.fireAt + setting.maxMs
910
975
  );
911
976
  }
912
- /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
977
+ /** 滑动窗口顺延:已到期的通道不复活 */
978
+ extend(channelKey, ruleName, lane, now) {
979
+ const setting = this.setting;
980
+ if (setting?.mode !== "sliding") return;
981
+ const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
982
+ if (!state || now >= state.deadline) return;
983
+ this.slide(state, setting, now);
984
+ }
985
+ /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
913
986
  extendDensityLanes(channelKey, now) {
914
- if (this.setting?.mode !== "sliding") return;
987
+ const setting = this.setting;
988
+ if (setting?.mode !== "sliding") return;
915
989
  const lanes = this.channels.get(channelKey);
916
990
  if (!lanes) return;
917
991
  for (const [key, state] of lanes) {
918
- if (!key.includes("\ndensity:")) continue;
919
- state.deadline = Math.min(
920
- Math.max(state.deadline, now + this.setting.minMs),
921
- state.fireAt + this.setting.maxMs
922
- );
992
+ if (!key.includes("\ndensity:") || now >= state.deadline) continue;
993
+ this.slide(state, setting, now);
923
994
  }
924
995
  }
925
996
  /** 冷却期内缓存一条命中消息(仅消息通道) */
@@ -931,22 +1002,24 @@ var TriggerCooldown = class {
931
1002
  state.buffer.splice(0, state.buffer.length - BUFFER_CAPACITY);
932
1003
  }
933
1004
  }
934
- /** 到期扫描:取走全部到期通道(连缓冲一并交出),由调用方决定是否重新登记 */
935
- collectExpired(now) {
1005
+ /** 到期扫描:取走到期通道(连缓冲一并交出),由调用方决定是否重新登记;限定频道时只扫该频道 */
1006
+ collectExpired(now, channelKey) {
936
1007
  const expired = [];
937
- for (const [channelKey, lanes] of this.channels) {
938
- for (const [key, state] of lanes) {
1008
+ const targets = channelKey ? [[channelKey, this.channels.get(channelKey)]] : this.channels;
1009
+ for (const [key, lanes] of targets) {
1010
+ if (!lanes) continue;
1011
+ for (const [laneKey, state] of lanes) {
939
1012
  if (now < state.deadline) continue;
940
- const separator = key.indexOf("\n");
941
- lanes.delete(key);
1013
+ const separator = laneKey.indexOf("\n");
1014
+ lanes.delete(laneKey);
942
1015
  expired.push({
943
- channelKey,
944
- ruleName: key.slice(0, separator),
945
- lane: key.slice(separator + 1),
1016
+ channelKey: key,
1017
+ ruleName: laneKey.slice(0, separator),
1018
+ lane: laneKey.slice(separator + 1),
946
1019
  buffer: state.buffer
947
1020
  });
948
1021
  }
949
- if (!lanes.size) this.channels.delete(channelKey);
1022
+ if (!lanes.size) this.channels.delete(key);
950
1023
  }
951
1024
  return expired;
952
1025
  }
@@ -962,6 +1035,29 @@ var TriggerCooldown = class {
962
1035
 
963
1036
  // src/trigger/engine.ts
964
1037
  var SCHEDULER_TICK_MS = 1e3;
1038
+ function collectImageUrls(entries) {
1039
+ return entries.flatMap((entry) => entry?.role === "user" ? entry.imageUrls ?? [] : []);
1040
+ }
1041
+ __name(collectImageUrls, "collectImageUrls");
1042
+ function sessionIdentityOf(session) {
1043
+ return {
1044
+ platform: session.platform,
1045
+ selfId: session.selfId,
1046
+ channelId: session.channelId,
1047
+ guildId: session.guildId ?? session.channelId,
1048
+ userId: session.userId
1049
+ };
1050
+ }
1051
+ __name(sessionIdentityOf, "sessionIdentityOf");
1052
+ function poolIdentityOf(channel) {
1053
+ return {
1054
+ platform: channel.platform,
1055
+ selfId: channel.selfId,
1056
+ channelId: channel.channelId,
1057
+ guildId: channel.guildId
1058
+ };
1059
+ }
1060
+ __name(poolIdentityOf, "poolIdentityOf");
965
1061
  var TriggerEngine = class {
966
1062
  constructor(ctx, runner, history, rules, cooldown) {
967
1063
  this.ctx = ctx;
@@ -998,6 +1094,7 @@ var TriggerEngine = class {
998
1094
  if (this.ctx.bots[session.uid]) return next();
999
1095
  if (!session.channelId) return next();
1000
1096
  const channelKey = channelKeyOf(session.platform, session.channelId);
1097
+ this.tickCooldown(channelKey);
1001
1098
  const entry = buildHistoryEntry(session);
1002
1099
  if (entry) {
1003
1100
  this.history.recordEntry(channelKey, entry);
@@ -1013,13 +1110,14 @@ var TriggerEngine = class {
1013
1110
  }
1014
1111
  }
1015
1112
  return next(async (nextMiddleware) => {
1113
+ this.tickCooldown(channelKey);
1016
1114
  const matched = this.matchRule(session);
1017
1115
  if (!matched) return nextMiddleware();
1018
1116
  if (this.cooldown.isCooling(channelKey, matched.rule.name, "message", Date.now())) {
1019
1117
  this.cooldown.bufferMessage(channelKey, matched.rule.name, {
1020
1118
  session,
1021
1119
  entry,
1022
- contentText: matched.condition.queryText
1120
+ contentText: matched.condition.contentText
1023
1121
  });
1024
1122
  this.cooldown.extend(channelKey, matched.rule.name, "message", Date.now());
1025
1123
  return;
@@ -1050,31 +1148,24 @@ var TriggerEngine = class {
1050
1148
  this.beginCooldown(channelKeyOf(session.platform, session.channelId), rule.name, "message");
1051
1149
  this.run({
1052
1150
  session,
1053
- platform: session.platform,
1054
- selfId: session.selfId,
1055
- channelId: session.channelId,
1056
- guildId: session.guildId ?? session.channelId,
1057
- userId: session.userId,
1151
+ ...sessionIdentityOf(session),
1058
1152
  triggerType: condition.type,
1059
1153
  rule,
1060
1154
  // query 为该条消息的统一格式单行;纯 @ / 纯引用等无文本消息 entry 为空,由请求器占位兜底
1061
1155
  queryText: entry ? formatHistoryEntry(entry) : "",
1062
- contentText: condition.queryText
1156
+ contentText: condition.contentText
1063
1157
  });
1064
1158
  }
1065
1159
  fireDensity(fire, session) {
1066
1160
  this.beginCooldown(channelKeyOf(session.platform, session.channelId), fire.rule.name, densityLane(fire.conditionIndex));
1067
1161
  this.run({
1068
1162
  session,
1069
- platform: session.platform,
1070
- selfId: session.selfId,
1071
- channelId: session.channelId,
1072
- guildId: session.guildId ?? session.channelId,
1073
- userId: session.userId,
1163
+ ...sessionIdentityOf(session),
1074
1164
  triggerType: "density",
1075
1165
  rule: fire.rule,
1076
1166
  queryText: formatHistoryEntries(fire.entries),
1077
- contentText: formatHistoryBrief(fire.entries)
1167
+ contentText: formatHistoryBrief(fire.entries),
1168
+ imageUrls: collectImageUrls(fire.entries)
1078
1169
  });
1079
1170
  }
1080
1171
  /** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
@@ -1085,29 +1176,37 @@ var TriggerEngine = class {
1085
1176
  this.beginCooldown(channelKeyOf(first.session.platform, first.session.channelId), rule.name, "message");
1086
1177
  this.run({
1087
1178
  session: first.session,
1088
- platform: first.session.platform,
1089
- selfId: first.session.selfId,
1090
- channelId: first.session.channelId,
1091
- guildId: first.session.guildId ?? first.session.channelId,
1092
- userId: first.session.userId,
1179
+ ...sessionIdentityOf(first.session),
1093
1180
  triggerType: "merged",
1094
1181
  rule,
1095
1182
  queryText,
1096
- contentText
1183
+ contentText,
1184
+ // 补齐缓冲内非首条消息的图片(首条由 session 元素提取,重复由上传入口去重)
1185
+ imageUrls: collectImageUrls(buffer.map((item) => item.entry))
1097
1186
  });
1098
1187
  }
1099
1188
  /** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
1100
- fireDensityExpiry(rule, conditionIndex, channel) {
1189
+ fireDensityExpiry(rule, conditionIndex, channel, now) {
1101
1190
  this.beginCooldown(channelKeyOf(channel.platform, channel.channelId), rule.name, densityLane(conditionIndex));
1191
+ const condition = rule.conditions[conditionIndex];
1192
+ const windowMs = condition.type === "density" ? condition.densityWindowSeconds * 1e3 : 0;
1193
+ let userId;
1194
+ for (let i = channel.entries.length - 1; i >= 0; i--) {
1195
+ const item = channel.entries[i];
1196
+ if (item.role === "user" && now - item.timestamp <= windowMs) {
1197
+ userId = item.userId;
1198
+ break;
1199
+ }
1200
+ }
1102
1201
  this.run({
1103
- platform: channel.platform,
1104
- selfId: channel.selfId,
1105
- channelId: channel.channelId,
1106
- guildId: channel.guildId,
1202
+ ...poolIdentityOf(channel),
1203
+ userId,
1107
1204
  triggerType: "density",
1108
1205
  rule,
1109
1206
  queryText: formatHistoryEntries(channel.entries),
1110
- contentText: formatHistoryBrief(channel.entries)
1207
+ contentText: formatHistoryBrief(channel.entries),
1208
+ imageUrls: collectImageUrls(channel.entries),
1209
+ isDirect: channel.isDirect
1111
1210
  });
1112
1211
  }
1113
1212
  /**
@@ -1117,22 +1216,21 @@ var TriggerEngine = class {
1117
1216
  fireSchedule(rule) {
1118
1217
  for (const channel of this.pool.takeSchedule(rule)) {
1119
1218
  this.run({
1120
- platform: channel.platform,
1121
- selfId: channel.selfId,
1122
- channelId: channel.channelId,
1123
- guildId: channel.guildId,
1219
+ ...poolIdentityOf(channel),
1124
1220
  triggerType: "schedule",
1125
1221
  rule,
1126
1222
  queryText: formatHistoryEntries(channel.entries),
1127
- contentText: formatHistoryBrief(channel.entries)
1223
+ contentText: formatHistoryBrief(channel.entries),
1224
+ imageUrls: collectImageUrls(channel.entries),
1225
+ isDirect: channel.isDirect
1128
1226
  });
1129
1227
  }
1130
1228
  }
1131
- /** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
1132
- tickCooldown() {
1229
+ /** 扫描冷却到期(带频道只扫该频道,消息路径用;每秒 interval 全量扫描):消息通道合并触发,密度通道复查池 */
1230
+ tickCooldown(channelKey) {
1133
1231
  if (!this.cooldown.enabled) return;
1134
1232
  const now = Date.now();
1135
- for (const expired of this.cooldown.collectExpired(now)) {
1233
+ for (const expired of this.cooldown.collectExpired(now, channelKey)) {
1136
1234
  this.onCooldownExpired(expired, now);
1137
1235
  }
1138
1236
  }
@@ -1143,20 +1241,14 @@ var TriggerEngine = class {
1143
1241
  if (expired.buffer.length) this.fireMerged(rule, expired.buffer);
1144
1242
  return;
1145
1243
  }
1146
- const index = Number(expired.lane.slice("density:".length));
1244
+ const index = Number(expired.lane.slice(DENSITY_LANE_PREFIX.length));
1147
1245
  const condition = rule.conditions[index];
1148
1246
  if (condition?.type !== "density") return;
1149
1247
  const channel = this.pool.takeDensityChannel(rule, condition, expired.channelKey, now);
1150
- if (channel) this.fireDensityExpiry(rule, index, channel);
1248
+ if (channel) this.fireDensityExpiry(rule, index, channel, now);
1151
1249
  }
1152
1250
  };
1153
1251
 
1154
- // src/types.ts
1155
- function isMessageTrigger(type) {
1156
- return type !== "schedule" && type !== "density";
1157
- }
1158
- __name(isMessageTrigger, "isMessageTrigger");
1159
-
1160
1252
  // src/requester/files.ts
1161
1253
  var import_promises = require("fs/promises");
1162
1254
  var import_fs = require("fs");
@@ -1239,11 +1331,26 @@ function extractFileCandidates(session) {
1239
1331
  return candidates;
1240
1332
  }
1241
1333
  __name(extractFileCandidates, "extractFileCandidates");
1334
+ function percentDecodeBytes(input) {
1335
+ const bytes = [];
1336
+ for (let i = 0; i < input.length; i++) {
1337
+ const char = input[i];
1338
+ if (char === "%" && /^[0-9a-fA-F]{2}$/.test(input.slice(i + 1, i + 3))) {
1339
+ bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
1340
+ i += 2;
1341
+ } else {
1342
+ bytes.push(char.charCodeAt(0) & 255);
1343
+ }
1344
+ }
1345
+ return Buffer.from(bytes);
1346
+ }
1347
+ __name(percentDecodeBytes, "percentDecodeBytes");
1242
1348
  function parseDataUrl(source) {
1243
- const match = source.match(/^data:([^;]+);base64,(.+)$/);
1349
+ const match = source.match(/^data:([^;,]*)((?:;[^;,]*)*)?,(.+)$/);
1244
1350
  if (!match) return null;
1245
- const mimeType = match[1];
1246
- const buffer = Buffer.from(match[2], "base64");
1351
+ const mimeType = match[1] || "application/octet-stream";
1352
+ const isBase64 = /(?:^|;)base64/.test(match[2] ?? "");
1353
+ const buffer = isBase64 ? Buffer.from(match[3], "base64") : percentDecodeBytes(match[3]);
1247
1354
  return { buffer, fileName: `file.${guessExtension(mimeType)}`, mimeType };
1248
1355
  }
1249
1356
  __name(parseDataUrl, "parseDataUrl");
@@ -1317,19 +1424,28 @@ async function uploadOne(ctx, client, candidate, difyUser, limit) {
1317
1424
  }
1318
1425
  }
1319
1426
  __name(uploadOne, "uploadOne");
1320
- async function uploadMessageFiles(ctx, client, session, difyUser, limit) {
1321
- const candidates = extractFileCandidates(session);
1322
- const limited = limit.maxCount > 0 ? candidates.slice(0, limit.maxCount) : candidates;
1323
- const dropped = candidates.slice(limited.length).map((candidate) => candidate.source);
1427
+ async function uploadCandidates(ctx, client, candidates, difyUser, limit) {
1428
+ const seen = /* @__PURE__ */ new Set();
1429
+ const unique = candidates.filter((candidate) => {
1430
+ if (seen.has(candidate.source)) return false;
1431
+ seen.add(candidate.source);
1432
+ return true;
1433
+ });
1434
+ const limited = limit.maxCount > 0 ? unique.slice(0, limit.maxCount) : unique;
1435
+ const dropped = [];
1436
+ const markDropped = /* @__PURE__ */ __name((source) => {
1437
+ if (!dropped.includes(source)) dropped.push(source);
1438
+ }, "markDropped");
1439
+ unique.slice(limited.length).forEach((candidate) => markDropped(candidate.source));
1324
1440
  const files = [];
1325
1441
  for (const candidate of limited) {
1326
1442
  const uploaded = await uploadOne(ctx, client, candidate, difyUser, limit);
1327
1443
  if (uploaded) files.push(uploaded);
1328
- else dropped.push(candidate.source);
1444
+ else markDropped(candidate.source);
1329
1445
  }
1330
1446
  return { files, dropped };
1331
1447
  }
1332
- __name(uploadMessageFiles, "uploadMessageFiles");
1448
+ __name(uploadCandidates, "uploadCandidates");
1333
1449
 
1334
1450
  // src/requester/variables.ts
1335
1451
  function resolveSessionField(key, fireCtx, now) {
@@ -1358,7 +1474,7 @@ function resolveSessionField(key, fireCtx, now) {
1358
1474
  case "timestamp":
1359
1475
  return now;
1360
1476
  case "isDirect":
1361
- return session?.isDirect ?? false;
1477
+ return session?.isDirect ?? fireCtx.isDirect ?? false;
1362
1478
  default:
1363
1479
  return "";
1364
1480
  }
@@ -1395,11 +1511,55 @@ function resolveSource(source, fireCtx, history, now) {
1395
1511
  }
1396
1512
  }
1397
1513
  __name(resolveSource, "resolveSource");
1514
+ var META_PLACEHOLDERS = /* @__PURE__ */ new Set(["triggerType", "ruleName", "triggerTime"]);
1515
+ function resolvePlaceholderValue(name2, limit, fireCtx, history, now) {
1516
+ if (name2 === "history") {
1517
+ const channelKey = channelKeyOf(fireCtx.platform, fireCtx.channelId);
1518
+ return history.toVariable(history.get(channelKey, limit ?? history.limit));
1519
+ }
1520
+ if (name2 === "isDirect") {
1521
+ return fireCtx.session?.isDirect ?? fireCtx.isDirect ?? false;
1522
+ }
1523
+ if (META_PLACEHOLDERS.has(name2)) {
1524
+ return String(resolveMetaField(name2, fireCtx, now) ?? "");
1525
+ }
1526
+ const value = resolveSessionField(name2, fireCtx, now);
1527
+ return value === void 0 || value === null ? "" : String(value);
1528
+ }
1529
+ __name(resolvePlaceholderValue, "resolvePlaceholderValue");
1530
+ var BARE_PLACEHOLDER = /"\{\{\s*(\w+)(?::(\d+))?\s*\}\}"/g;
1531
+ var escapeJsonText = /* @__PURE__ */ __name((value) => JSON.stringify(value).slice(1, -1), "escapeJsonText");
1532
+ function interpolateTemplate(template, fireCtx, history, now) {
1533
+ const resolve = /* @__PURE__ */ __name((name2, limit) => resolvePlaceholderValue(name2, limit ? Number(limit) : void 0, fireCtx, history, now), "resolve");
1534
+ const native = template.replace(
1535
+ BARE_PLACEHOLDER,
1536
+ (_match, name2, limit) => JSON.stringify(resolve(name2, limit))
1537
+ );
1538
+ return native.replace(
1539
+ JSON_PLACEHOLDER,
1540
+ (_match, name2, limit) => escapeJsonText(String(resolve(name2, limit)))
1541
+ );
1542
+ }
1543
+ __name(interpolateTemplate, "interpolateTemplate");
1544
+ function resolveJsonObject(template, fireCtx, history, now) {
1545
+ try {
1546
+ const parsed = JSON.parse(interpolateTemplate(template, fireCtx, history, now));
1547
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
1548
+ } catch {
1549
+ return null;
1550
+ }
1551
+ }
1552
+ __name(resolveJsonObject, "resolveJsonObject");
1398
1553
  function resolveVariables(rule, fireCtx, history) {
1399
1554
  const inputs = {};
1400
1555
  const now = (/* @__PURE__ */ new Date()).toISOString();
1401
1556
  for (const binding of rule.variables) {
1402
- if (!binding?.field) continue;
1557
+ if (binding?.source?.type !== "json") continue;
1558
+ const object = resolveJsonObject(binding.source.template, fireCtx, history, now);
1559
+ if (object) Object.assign(inputs, object);
1560
+ }
1561
+ for (const binding of rule.variables) {
1562
+ if (!binding?.field || binding.source?.type === "json") continue;
1403
1563
  const value = resolveSource(binding.source, fireCtx, history, now);
1404
1564
  if (value !== void 0) inputs[binding.field] = value;
1405
1565
  }
@@ -1440,7 +1600,7 @@ ${line}` : line;
1440
1600
  __name(splitText, "splitText");
1441
1601
  function renderReply(input) {
1442
1602
  const messages = [];
1443
- const segments = splitText(input.text, input.config.maxSegmentLength ?? 0);
1603
+ const segments = splitText(input.text, input.config.maxSegmentLength);
1444
1604
  segments.forEach((segment, index) => {
1445
1605
  const elements = [];
1446
1606
  if (index === 0 && input.quoteMessageId) {
@@ -1496,6 +1656,7 @@ function extractTextOutputs(outputs) {
1496
1656
  __name(extractTextOutputs, "extractTextOutputs");
1497
1657
  function parseWorkflowResponse(response) {
1498
1658
  const data = response.data;
1659
+ if (!data) throw new DifyApiError("workflow 响应缺少 data", 200);
1499
1660
  if (data.error) throw new DifyApiError(data.error, 200);
1500
1661
  if (data.status && !SUCCESS_STATUSES.includes(data.status)) {
1501
1662
  throw new DifyApiError(`workflow ${data.status}`, 200);
@@ -1538,11 +1699,13 @@ var RequestRunner = class {
1538
1699
  uploadedFiles: []
1539
1700
  };
1540
1701
  const upload = resolveUploadConfig(this.config);
1541
- if (upload.enabled && fireCtx.session) {
1542
- const { files, dropped } = await uploadMessageFiles(
1702
+ if (upload.enabled) {
1703
+ const sessionCandidates = fireCtx.session ? extractFileCandidates(fireCtx.session) : [];
1704
+ const urlCandidates = (fireCtx.imageUrls ?? []).filter((url) => !sessionCandidates.some((candidate) => candidate.source === url)).map((url) => ({ source: url, type: "image" }));
1705
+ const { files, dropped } = await uploadCandidates(
1543
1706
  this.ctx,
1544
1707
  client,
1545
- fireCtx.session,
1708
+ [...sessionCandidates, ...urlCandidates],
1546
1709
  requestCtx.difyUser,
1547
1710
  { maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
1548
1711
  );
@@ -1566,17 +1729,20 @@ var RequestRunner = class {
1566
1729
  this.ctx.logger.warn("规则 %s 的请求未返回任何内容", rule.name);
1567
1730
  return;
1568
1731
  }
1569
- const quote = resolveQuoteConfig(this.config);
1570
- const elapsedSeconds = (Date.now() - startedAt) / 1e3;
1571
- const shouldQuote = quote.enabled && elapsedSeconds >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
1572
1732
  const messages = renderReply({
1573
1733
  text: reply.text,
1574
1734
  files: reply.files,
1575
- quoteMessageId: shouldQuote ? fireCtx.session?.messageId : void 0,
1735
+ quoteMessageId: this.shouldQuoteReply(fireCtx, startedAt) ? fireCtx.session?.messageId : void 0,
1576
1736
  config: this.config
1577
1737
  });
1578
1738
  await this.deliver(fireCtx, messages);
1579
1739
  }
1740
+ /** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
1741
+ shouldQuoteReply(fireCtx, startedAt) {
1742
+ const quote = resolveQuoteConfig(this.config);
1743
+ if (!quote.enabled) return false;
1744
+ return (Date.now() - startedAt) / 1e3 >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
1745
+ }
1580
1746
  /** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
1581
1747
  async dispatch(app, client, fireCtx, requestCtx) {
1582
1748
  const base = {
@@ -1614,7 +1780,8 @@ var RequestRunner = class {
1614
1780
  }
1615
1781
  return;
1616
1782
  }
1617
- const bot = Object.values(this.ctx.bots).find((b) => b.platform === fireCtx.platform);
1783
+ const bots = Object.values(this.ctx.bots);
1784
+ const bot = bots.find((b) => b.platform === fireCtx.platform && b.selfId === fireCtx.selfId) ?? bots.find((b) => b.platform === fireCtx.platform);
1618
1785
  if (!bot) {
1619
1786
  this.ctx.logger.warn("平台 %s 上没有可用 bot,无法发送主动消息", fireCtx.platform);
1620
1787
  return;
@@ -1636,7 +1803,7 @@ var ConversationStore = class {
1636
1803
  }
1637
1804
  /** 粒度归一:bindingKey 与 difyUser 共用,保证两者始终对齐 */
1638
1805
  resolveScopeDimension(rule, fireCtx) {
1639
- if (fireCtx.session?.isDirect || rule.conversationScope === "per-user") return "user";
1806
+ if (this.isDirectOf(fireCtx) || rule.conversationScope === "per-user") return "user";
1640
1807
  switch (rule.conversationScope) {
1641
1808
  case "global":
1642
1809
  return "global";
@@ -1646,13 +1813,17 @@ var ConversationStore = class {
1646
1813
  return "guild";
1647
1814
  }
1648
1815
  }
1816
+ /** 私聊判定:有 session 读 session,无 session 的主动触发读池透传的标记 */
1817
+ isDirectOf(fireCtx) {
1818
+ return fireCtx.session?.isDirect ?? fireCtx.isDirect ?? false;
1819
+ }
1649
1820
  /** bindingKey = 应用 ID + 粒度 + 平台身份 */
1650
1821
  computeBindingKey(appId, rule, fireCtx) {
1651
1822
  const base = `${appId}:${fireCtx.platform}:${fireCtx.selfId}`;
1652
1823
  switch (this.resolveScopeDimension(rule, fireCtx)) {
1653
1824
  // 群内按「群+用户」区分;私聊用 private 段占位
1654
1825
  case "user":
1655
- return fireCtx.session?.isDirect ? `${base}:private:${fireCtx.userId ?? fireCtx.channelId}` : `${base}:user:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1826
+ return this.isDirectOf(fireCtx) ? `${base}:private:${fireCtx.userId ?? fireCtx.channelId}` : `${base}:user:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1656
1827
  case "guild":
1657
1828
  return `${base}:guild:${fireCtx.guildId ?? fireCtx.channelId}`;
1658
1829
  case "channel":
@@ -1669,7 +1840,7 @@ var ConversationStore = class {
1669
1840
  computeDifyUser(rule, fireCtx) {
1670
1841
  switch (this.resolveScopeDimension(rule, fireCtx)) {
1671
1842
  case "user":
1672
- return fireCtx.session?.isDirect ? `koishi:${fireCtx.platform}:private:${fireCtx.userId ?? fireCtx.channelId}` : `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1843
+ return this.isDirectOf(fireCtx) ? `koishi:${fireCtx.platform}:private:${fireCtx.userId ?? fireCtx.channelId}` : `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1673
1844
  case "guild":
1674
1845
  return `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}`;
1675
1846
  case "channel":
@@ -1709,16 +1880,25 @@ var ConversationStore = class {
1709
1880
  /**
1710
1881
  * 重置匹配的绑定:本地删行 + 尽力删除 Dify 侧会话(失败仅记录)。
1711
1882
  * 表已无 guildKey 列,按频道匹配时全表读回后解析 bindingKey 过滤(行数量级很小)。
1883
+ * 覆盖 guild / user(群内)/ channel 段与私聊执行时的 private 段;
1884
+ * global 段跨频道,不随单频道重置。
1712
1885
  */
1713
1886
  async resetWhere(filter) {
1714
1887
  const rows = await this.ctx.database.get("dify_conversation", {});
1888
+ const splitKey = /* @__PURE__ */ __name((key) => {
1889
+ const separator = key.indexOf(":");
1890
+ return separator < 0 ? [key, ""] : [key.slice(0, separator), key.slice(separator + 1)];
1891
+ }, "splitKey");
1892
+ const [guildPlatform, guildId] = splitKey(filter.guildKey ?? "");
1893
+ const [channelPlatform, channelId] = splitKey(filter.channelKey ?? "");
1715
1894
  const matched = rows.filter((row) => {
1716
1895
  if (filter.bindingKey) return row.bindingKey === filter.bindingKey;
1717
1896
  const { platform, scope, ids } = this.parseBindingKey(row.bindingKey);
1718
- const [guildPlatform, guildId] = (filter.guildKey ?? "").split(":");
1719
1897
  if (platform !== guildPlatform) return false;
1720
1898
  if (scope === "guild") return ids[0] === guildId;
1721
1899
  if (scope === "user") return ids.length === 2 ? ids[0] === guildId : false;
1900
+ if (scope === "channel") return platform === channelPlatform && ids[0] === channelId;
1901
+ if (scope === "private") return filter.isDirect === true && ids[0] === filter.userId;
1722
1902
  return false;
1723
1903
  });
1724
1904
  let reset = 0;
@@ -1761,8 +1941,12 @@ __name(defineTables, "defineTables");
1761
1941
  // src/commands/misc.ts
1762
1942
  function registerMiscCommands(parent, conversationStore) {
1763
1943
  parent.subcommand(".conv.reset", { authority: 3 }).action(async ({ session }) => {
1764
- const guildKey = guildKeyOf(session.platform, session.guildId, session.channelId);
1765
- const reset = await conversationStore.resetWhere({ guildKey });
1944
+ const reset = await conversationStore.resetWhere({
1945
+ guildKey: guildKeyOf(session.platform, session.guildId, session.channelId),
1946
+ channelKey: channelKeyOf(session.platform, session.channelId),
1947
+ isDirect: session.isDirect,
1948
+ userId: session.userId
1949
+ });
1766
1950
  if (!reset) return session.text("dify-ai.commands.conv-empty");
1767
1951
  return session.text("dify-ai.commands.conv-reset", [reset]);
1768
1952
  });
@@ -1772,7 +1956,9 @@ __name(registerMiscCommands, "registerMiscCommands");
1772
1956
  // src/index.ts
1773
1957
  var name = "dify-ai";
1774
1958
  var inject = { required: ["database"] };
1959
+ var PROBE_TIMEOUT_MS = 1e4;
1775
1960
  function apply(ctx, config) {
1961
+ const startedAt = Date.now();
1776
1962
  ctx.i18n.define("zh-CN", zh_CN_default);
1777
1963
  ctx.i18n.define("en-US", en_US_default);
1778
1964
  defineTables(ctx);
@@ -1782,18 +1968,24 @@ function apply(ctx, config) {
1782
1968
  const conversationStore = new ConversationStore(ctx, apps);
1783
1969
  const runner = new RequestRunner(ctx, config, conversationStore, history, apps, appTypes);
1784
1970
  const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
1785
- ctx.on("ready", async () => {
1786
- await probeAppTypes(ctx, config, apps, appTypes);
1971
+ ctx.on("ready", () => {
1787
1972
  engine.start();
1973
+ ctx.logger.info("插件装配完成,耗时 %dms", Date.now() - startedAt);
1974
+ const probeStart = Date.now();
1975
+ void probeAppTypes(ctx, apps, appTypes).then(() => {
1976
+ ctx.logger.info("应用类型探测完成,耗时 %dms", Date.now() - probeStart);
1977
+ });
1788
1978
  });
1789
1979
  const admin = ctx.command("dify", { authority: 3 });
1790
1980
  registerMiscCommands(admin, conversationStore);
1791
1981
  }
1792
1982
  __name(apply, "apply");
1793
- async function probeAppTypes(ctx, config, apps, appTypes) {
1983
+ async function probeAppTypes(ctx, apps, appTypes) {
1984
+ const autoApps = [...apps.values()].filter((app) => app.appType === "auto");
1985
+ if (!autoApps.length) return;
1794
1986
  await Promise.all(
1795
- [...apps.values()].filter((app) => app.appType === "auto").map(async (app) => {
1796
- const client = createDifyClient(ctx, app, config.requestTimeout);
1987
+ autoApps.map(async (app) => {
1988
+ const client = createDifyClient(ctx, app, PROBE_TIMEOUT_MS);
1797
1989
  if (!client) return;
1798
1990
  try {
1799
1991
  const info = await client.getAppInfo();
@@ -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>;
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;
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.6",
4
+ "version": "0.1.9",
5
5
  "author": "PinkElysia",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/PinkElysiaDev/dify-ai-plugin-for-Koishi#readme",