koishi-plugin-dify-ai 0.1.5 → 0.1.7

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.
@@ -18,7 +18,6 @@ export declare class DifyClient {
18
18
  /** blocking JSON 请求(本插件只用 blocking 模式) */
19
19
  private request;
20
20
  getAppInfo(): Promise<DifyAppInfo>;
21
- getParameters(): Promise<any>;
22
21
  /** chat / agent-chat / advanced-chat(conversation_id 传空串表示新建会话) */
23
22
  runChat(body: {
24
23
  inputs: Record<string, any>;
package/lib/config.d.ts CHANGED
@@ -120,8 +120,9 @@ export declare function resolveUploadConfig(config: Config): {
120
120
  maxMb: number;
121
121
  };
122
122
  /**
123
- * 引用触发消息配置(含 0.1.x 布尔开关迁移):schema 形态缺失时
124
- * 旧 quoteTrigger 若被配置层透传保留则迁移,否则按总是引用处理。
123
+ * 引用触发消息配置(含 0.1.x 布尔开关迁移):schema 会给旧配置填默认 quote 对象,
124
+ * 因此迁移判据是「旧布尔被配置层透传保留 且 quote 仍是默认形态(用户未在新表单改过)」——
125
+ * 此时以旧布尔为准;用户改过新表单(quote 非默认)则新值优先。
125
126
  */
126
127
  export declare function resolveQuoteConfig(config: Config): {
127
128
  enabled: boolean;
package/lib/history.d.ts CHANGED
@@ -11,8 +11,12 @@ export interface HistoryEntry {
11
11
  userId: string;
12
12
  username: string;
13
13
  messageId?: string;
14
- /** 纯文本 + 图片占位符 */
14
+ /** 原貌(含 @bot 渲染与图片占位):统一格式行(query/历史变量)用 */
15
15
  content: string;
16
+ /** 剔除 @bot 的原貌:content 变量与精简行用 */
17
+ contentText: string;
18
+ /** 图片元素 URL(按元素序,与 content 中 [image:摘要] 序一致):窗口类触发的上传依据 */
19
+ imageUrls?: string[];
16
20
  timestamp: number;
17
21
  quote?: QuotedMessage;
18
22
  }
@@ -24,6 +28,8 @@ export interface HistoryEntry {
24
28
  export declare function formatHistoryEntry(entry: HistoryEntry): string;
25
29
  /** 多条消息逐行拼接 */
26
30
  export declare function formatHistoryEntries(entries: HistoryEntry[]): string;
31
+ /** 精简行:只记消息 ID 与剔除 @bot 的原貌内容,不带时间与作者前缀——窗口触发的 content 变量用 */
32
+ export declare function formatHistoryBrief(entries: HistoryEntry[]): string;
27
33
  /** 从会话消息构造记录条目(历史缓冲 / 消息池共用);assistant 行标注为机器人自己的发言 */
28
34
  export declare function buildHistoryEntry(session: Session, role?: 'user' | 'assistant'): HistoryEntry | null;
29
35
  /**
package/lib/index.js CHANGED
@@ -165,7 +165,8 @@ var uploadSchema = import_koishi.Schema.union([
165
165
  mode: import_koishi.Schema.const("on").required(),
166
166
  maxCount: import_koishi.Schema.natural().default(0).description("数量上限,0 不限"),
167
167
  maxSizeMb: import_koishi.Schema.natural().default(0).description("单文件大小上限 MB,0 不限")
168
- }).default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("开启")
168
+ }).default({ mode: "on", maxCount: 0, maxSizeMb: 0 }).description("开启"),
169
+ import_koishi.Schema.boolean().description("旧版开关")
169
170
  ]);
170
171
  var Config = import_koishi.Schema.intersect([
171
172
  import_koishi.Schema.object({
@@ -316,14 +317,14 @@ function toVariable(entry) {
316
317
  }
317
318
  if (entry.source === "meta") {
318
319
  if (entry.key === "none") return null;
319
- return { field, source: { type: "meta", key: entry.key || "triggerType" } };
320
+ return { field, source: { type: "meta", key: entry.key } };
320
321
  }
321
322
  if (entry.source === "history") {
322
323
  const limit = Number(entry.limit);
323
324
  return { field, source: { type: "history", ...limit > 0 ? { limit } : {} } };
324
325
  }
325
326
  if (entry.key === "none" || entry.key === "history") return null;
326
- return { field, source: { type: "session", key: entry.key || "content" } };
327
+ return { field, source: { type: "session", key: entry.key } };
327
328
  }
328
329
  __name(toVariable, "toVariable");
329
330
  function resolveUploadConfig(config) {
@@ -333,34 +334,29 @@ function resolveUploadConfig(config) {
333
334
  if (config.uploadFiles?.mode === "off") return { enabled: false, maxCount: 0, maxMb: 0 };
334
335
  return {
335
336
  enabled: true,
336
- maxCount: Math.max(0, Number(config.uploadFiles?.maxCount) || 0),
337
- maxMb: Math.max(0, Number(config.uploadFiles?.maxSizeMb) || 0)
337
+ maxCount: Number(config.uploadFiles?.maxCount) || 0,
338
+ maxMb: Number(config.uploadFiles?.maxSizeMb) || 0
338
339
  };
339
340
  }
340
341
  __name(resolveUploadConfig, "resolveUploadConfig");
341
342
  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) };
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 };
345
347
  }
346
- if (config.quoteTrigger === false) return { enabled: false, minElapsedSeconds: 0 };
347
- return { enabled: true, minElapsedSeconds: 0 };
348
+ if (quote?.mode === "off") return { enabled: false, minElapsedSeconds: 0 };
349
+ return { enabled: true, minElapsedSeconds: Number(quote?.minElapsedSeconds) || 0 };
348
350
  }
349
351
  __name(resolveQuoteConfig, "resolveQuoteConfig");
350
352
  function toCooldown(entry) {
351
353
  if (entry?.mode === "fixed") {
352
- const seconds = Number(entry.seconds);
353
- return seconds > 0 ? { mode: "fixed", ms: seconds * 1e3 } : null;
354
+ return { mode: "fixed", ms: Number(entry.seconds) * 1e3 };
354
355
  }
355
356
  if (entry?.mode === "sliding") {
356
357
  const minSeconds = Number(entry.minSeconds);
357
358
  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
- };
359
+ return { mode: "sliding", minMs: minSeconds * 1e3, maxMs: Math.max(minSeconds, maxSeconds) * 1e3 };
364
360
  }
365
361
  return null;
366
362
  }
@@ -371,12 +367,16 @@ function normalizeConfig(config) {
371
367
  if (app.name?.trim() && !apps.has(app.name)) apps.set(app.name, app);
372
368
  }
373
369
  const rules = [];
370
+ const seenNames = /* @__PURE__ */ new Set();
374
371
  for (const entry of config.rules ?? []) {
375
372
  if (!entry.name?.trim() || entry.enabled === false) continue;
373
+ const name2 = entry.name.trim();
374
+ if (seenNames.has(name2)) continue;
375
+ seenNames.add(name2);
376
376
  const conditions = (entry.conditions ?? []).filter((condition) => condition != null).map(toCondition).filter((condition) => condition != null);
377
377
  const variables = (entry.variables ?? []).filter((variable) => variable != null).map(toVariable).filter((variable) => variable != null);
378
378
  rules.push({
379
- name: entry.name.trim(),
379
+ name: name2,
380
380
  enabled: true,
381
381
  priority: Number(entry.priority) || 0,
382
382
  app: entry.app?.trim() ?? "",
@@ -449,9 +449,6 @@ var DifyClient = class {
449
449
  getAppInfo() {
450
450
  return this.request("GET", "/info");
451
451
  }
452
- getParameters() {
453
- return this.request("GET", "/parameters");
454
- }
455
452
  /** chat / agent-chat / advanced-chat(conversation_id 传空串表示新建会话) */
456
453
  runChat(body) {
457
454
  return this.request("POST", "/chat-messages", body);
@@ -500,6 +497,7 @@ __name(createDifyClient, "createDifyClient");
500
497
  // src/utils/session-text.ts
501
498
  var import_crypto = require("crypto");
502
499
  var import_koishi2 = require("koishi");
500
+ var IMAGE_HASH_LENGTH = 8;
503
501
  function parseTextSafe(content) {
504
502
  if (!content) return "";
505
503
  try {
@@ -509,10 +507,9 @@ function parseTextSafe(content) {
509
507
  }
510
508
  }
511
509
  __name(parseTextSafe, "parseTextSafe");
512
- function extractText(session, options = {}) {
510
+ function extractText(session) {
513
511
  if (session.elements?.length) {
514
- const elements = options.excludeAt ? session.elements.filter((element) => element.type !== "at") : session.elements;
515
- return import_koishi2.h.select(elements, "text").join("").trim();
512
+ return import_koishi2.h.select(session.elements, "text").join("").trim();
516
513
  }
517
514
  return parseTextSafe(session.content);
518
515
  }
@@ -520,7 +517,7 @@ __name(extractText, "extractText");
520
517
  var IMAGE_PLACEHOLDER = "[图片]";
521
518
  function imagePlaceholder(url) {
522
519
  if (!url) return IMAGE_PLACEHOLDER;
523
- return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0, 8)}]`;
520
+ return `[image:${(0, import_crypto.createHash)("sha1").update(url).digest("hex").slice(0, IMAGE_HASH_LENGTH)}]`;
524
521
  }
525
522
  __name(imagePlaceholder, "imagePlaceholder");
526
523
  function annotateDroppedPlaceholders(text, droppedUrls) {
@@ -532,19 +529,25 @@ function annotateDroppedPlaceholders(text, droppedUrls) {
532
529
  return result;
533
530
  }
534
531
  __name(annotateDroppedPlaceholders, "annotateDroppedPlaceholders");
535
- function extractTextWithPlaceholders(session) {
536
- const elements = session.elements?.length ? session.elements : (() => {
537
- try {
538
- return import_koishi2.h.parse(session.content ?? "");
539
- } catch {
540
- return [];
541
- }
542
- })();
532
+ function elementsOf(session) {
533
+ if (session.elements?.length) return session.elements;
534
+ try {
535
+ return import_koishi2.h.parse(session.content ?? "");
536
+ } catch {
537
+ return [];
538
+ }
539
+ }
540
+ __name(elementsOf, "elementsOf");
541
+ function extractTextWithPlaceholders(session, options = {}) {
543
542
  const parts = [];
544
- for (const element of elements) {
543
+ for (const element of elementsOf(session)) {
545
544
  if (element.type === "text") {
546
545
  const text = element.attrs?.["content"] ?? "";
547
546
  if (text) parts.push(text);
547
+ } else if (element.type === "at") {
548
+ if (options.excludeBotAt && element.attrs?.["id"] === session.selfId) continue;
549
+ const name2 = element.attrs?.["name"] ?? element.attrs?.["id"];
550
+ if (name2) parts.push(`@${name2}`);
548
551
  } else if (element.type === "img" || element.type === "image") {
549
552
  parts.push(imagePlaceholder(element.attrs?.["url"] ?? element.attrs?.["src"]));
550
553
  }
@@ -552,6 +555,16 @@ function extractTextWithPlaceholders(session) {
552
555
  return parts.join(" ").replace(/\s+/g, " ").trim();
553
556
  }
554
557
  __name(extractTextWithPlaceholders, "extractTextWithPlaceholders");
558
+ function extractImageUrls(session) {
559
+ const urls = [];
560
+ for (const element of elementsOf(session)) {
561
+ if (element.type !== "img" && element.type !== "image") continue;
562
+ const source = element.attrs?.["url"] ?? element.attrs?.["src"];
563
+ if (typeof source === "string" && source) urls.push(source);
564
+ }
565
+ return urls;
566
+ }
567
+ __name(extractImageUrls, "extractImageUrls");
555
568
 
556
569
  // src/history.ts
557
570
  var HISTORY_CAPACITY_MULTIPLIER = 2;
@@ -572,6 +585,10 @@ function formatHistoryEntries(entries) {
572
585
  return entries.map(formatHistoryEntry).join("\n");
573
586
  }
574
587
  __name(formatHistoryEntries, "formatHistoryEntries");
588
+ function formatHistoryBrief(entries) {
589
+ return entries.map((entry) => entry.messageId ? `[msg:${entry.messageId}] ${entry.contentText}` : entry.contentText).join("\n");
590
+ }
591
+ __name(formatHistoryBrief, "formatHistoryBrief");
575
592
  function buildQuote(session) {
576
593
  const quote = session.quote;
577
594
  if (!quote?.user?.id) return void 0;
@@ -586,12 +603,15 @@ __name(buildQuote, "buildQuote");
586
603
  function buildHistoryEntry(session, role = "user") {
587
604
  const content = extractTextWithPlaceholders(session);
588
605
  if (!content) return null;
606
+ const imageUrls = extractImageUrls(session);
589
607
  return {
590
608
  role,
591
609
  userId: role === "assistant" ? session.selfId : session.userId ?? "未知用户",
592
- username: role === "assistant" ? "你自己的发言" : session.username || session.userId || "用户",
610
+ username: role === "assistant" ? "你自己的发言" : session.username || session.userId,
593
611
  messageId: session.messageId,
594
612
  content,
613
+ contentText: extractTextWithPlaceholders(session, { excludeBotAt: true }),
614
+ ...imageUrls.length ? { imageUrls } : {},
595
615
  timestamp: session.timestamp ?? Date.now(),
596
616
  quote: role === "user" ? buildQuote(session) : void 0
597
617
  };
@@ -629,40 +649,49 @@ function hasAt(session, id) {
629
649
  return session.elements?.some((element) => element.type === "at" && element.attrs?.["id"] === id) ?? false;
630
650
  }
631
651
  __name(hasAt, "hasAt");
652
+ var NON_TEXT_PREFIX = new RegExp(`^(?:@\\S+|\\[image:[0-9a-f]{${IMAGE_HASH_LENGTH}}\\]|\\[图片\\]|\\s)*`);
653
+ function stripPrefixKeyword(verbatim, keyword) {
654
+ const prefix = NON_TEXT_PREFIX.exec(verbatim)?.[0] ?? "";
655
+ const rest = verbatim.slice(prefix.length);
656
+ if (!rest.startsWith(keyword)) return null;
657
+ return (prefix + rest.slice(keyword.length)).trim();
658
+ }
659
+ __name(stripPrefixKeyword, "stripPrefixKeyword");
632
660
  function evaluateCondition(condition, session) {
633
661
  const text = extractText(session);
662
+ const verbatim = extractTextWithPlaceholders(session, { excludeBotAt: true });
634
663
  switch (condition.type) {
635
664
  case "keyword-prefix": {
636
665
  const keyword = condition.keywords.find((k) => k && text.startsWith(k));
637
666
  if (!keyword) return null;
638
- const rest = text.slice(keyword.length).trim();
639
- return { type: "keyword-prefix", queryText: rest || text };
667
+ const rest = verbatim.startsWith(keyword) ? verbatim.slice(keyword.length).trim() : stripPrefixKeyword(verbatim, keyword) ?? text.slice(keyword.length).trim();
668
+ return { type: "keyword-prefix", contentText: rest || verbatim };
640
669
  }
641
670
  case "keyword-contains": {
642
671
  const keyword = condition.keywords.find((k) => k && text.includes(k));
643
672
  if (!keyword) return null;
644
- return { type: "keyword-contains", queryText: text };
673
+ return { type: "keyword-contains", contentText: verbatim };
645
674
  }
646
675
  case "at-bot": {
647
676
  const botId = session.bot?.userId;
648
677
  if (!botId) return null;
649
678
  if (!session.stripped?.atSelf && !hasAt(session, botId)) return null;
650
- return { type: "at-bot", queryText: extractText(session, { excludeAt: true }) };
679
+ return { type: "at-bot", contentText: verbatim };
651
680
  }
652
681
  case "at-user": {
653
682
  const isAtAnyUser = condition.userIds.some((id) => hasAt(session, id));
654
683
  if (!isAtAnyUser) return null;
655
- return { type: "at-user", queryText: text };
684
+ return { type: "at-user", contentText: verbatim };
656
685
  }
657
686
  case "quote-bot": {
658
687
  const botId = session.bot?.userId;
659
688
  if (!botId || session.quote?.user?.id !== botId) return null;
660
- return { type: "quote-bot", queryText: extractText(session, { excludeAt: true }) };
689
+ return { type: "quote-bot", contentText: verbatim };
661
690
  }
662
691
  case "quote-user": {
663
692
  const author = session.quote?.user?.id;
664
693
  if (!author || !condition.userIds.includes(author)) return null;
665
- return { type: "quote-user", queryText: extractText(session, { excludeAt: true }) };
694
+ return { type: "quote-user", contentText: verbatim };
666
695
  }
667
696
  default:
668
697
  return null;
@@ -801,13 +830,7 @@ var RuleScheduler = class {
801
830
  }
802
831
  entries = [];
803
832
  sync(rules) {
804
- const previous = new Map(this.entries.map((entry) => [entry.rule.name, entry.nextRunAt]));
805
- const nextEntries = [];
806
- for (const rule of rules) {
807
- const nextRunAt = previous.get(rule.name) ?? this.computeNextRun(rule);
808
- if (nextRunAt != null) nextEntries.push({ rule, nextRunAt });
809
- }
810
- this.entries = nextEntries;
833
+ this.entries = rules.map((rule) => ({ rule, nextRunAt: this.computeNextRun(rule) })).filter((entry) => entry.nextRunAt != null);
811
834
  }
812
835
  tick() {
813
836
  const now = Date.now();
@@ -857,8 +880,9 @@ var RuleScheduler = class {
857
880
 
858
881
  // src/trigger/cooldown.ts
859
882
  var BUFFER_CAPACITY = 1024;
883
+ var DENSITY_LANE_PREFIX = "density:";
860
884
  function densityLane(index) {
861
- return `density:${index}`;
885
+ return `${DENSITY_LANE_PREFIX}${index}`;
862
886
  }
863
887
  __name(densityLane, "densityLane");
864
888
  function laneKeyOf(ruleName, lane) {
@@ -889,30 +913,33 @@ var TriggerCooldown = class {
889
913
  });
890
914
  }
891
915
  isCooling(channelKey, ruleName, lane, now) {
892
- const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
916
+ const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
893
917
  return state != null && now < state.deadline;
894
918
  }
895
- /** 滑动窗口顺延:截止时间顺延到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
896
- extend(channelKey, ruleName, lane, now) {
897
- if (this.setting?.mode !== "sliding") return;
898
- const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
899
- if (!state) return;
919
+ /** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
920
+ slide(state, setting, now) {
900
921
  state.deadline = Math.min(
901
- Math.max(state.deadline, now + this.setting.minMs),
902
- state.fireAt + this.setting.maxMs
922
+ Math.max(state.deadline, now + setting.minMs),
923
+ state.fireAt + setting.maxMs
903
924
  );
904
925
  }
905
- /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
926
+ /** 滑动窗口顺延:已到期的通道不复活 */
927
+ extend(channelKey, ruleName, lane, now) {
928
+ const setting = this.setting;
929
+ if (setting?.mode !== "sliding") return;
930
+ const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
931
+ if (!state || now >= state.deadline) return;
932
+ this.slide(state, setting, now);
933
+ }
934
+ /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
906
935
  extendDensityLanes(channelKey, now) {
907
- if (this.setting?.mode !== "sliding") return;
936
+ const setting = this.setting;
937
+ if (setting?.mode !== "sliding") return;
908
938
  const lanes = this.channels.get(channelKey);
909
939
  if (!lanes) return;
910
940
  for (const [key, state] of lanes) {
911
- if (!key.includes("\ndensity:")) continue;
912
- state.deadline = Math.min(
913
- Math.max(state.deadline, now + this.setting.minMs),
914
- state.fireAt + this.setting.maxMs
915
- );
941
+ if (!key.includes("\ndensity:") || now >= state.deadline) continue;
942
+ this.slide(state, setting, now);
916
943
  }
917
944
  }
918
945
  /** 冷却期内缓存一条命中消息(仅消息通道) */
@@ -924,22 +951,24 @@ var TriggerCooldown = class {
924
951
  state.buffer.splice(0, state.buffer.length - BUFFER_CAPACITY);
925
952
  }
926
953
  }
927
- /** 到期扫描:取走全部到期通道(连缓冲一并交出),由调用方决定是否重新登记 */
928
- collectExpired(now) {
954
+ /** 到期扫描:取走到期通道(连缓冲一并交出),由调用方决定是否重新登记;限定频道时只扫该频道 */
955
+ collectExpired(now, channelKey) {
929
956
  const expired = [];
930
- for (const [channelKey, lanes] of this.channels) {
931
- for (const [key, state] of lanes) {
957
+ const targets = channelKey ? [[channelKey, this.channels.get(channelKey)]] : this.channels;
958
+ for (const [key, lanes] of targets) {
959
+ if (!lanes) continue;
960
+ for (const [laneKey, state] of lanes) {
932
961
  if (now < state.deadline) continue;
933
- const separator = key.indexOf("\n");
934
- lanes.delete(key);
962
+ const separator = laneKey.indexOf("\n");
963
+ lanes.delete(laneKey);
935
964
  expired.push({
936
- channelKey,
937
- ruleName: key.slice(0, separator),
938
- lane: key.slice(separator + 1),
965
+ channelKey: key,
966
+ ruleName: laneKey.slice(0, separator),
967
+ lane: laneKey.slice(separator + 1),
939
968
  buffer: state.buffer
940
969
  });
941
970
  }
942
- if (!lanes.size) this.channels.delete(channelKey);
971
+ if (!lanes.size) this.channels.delete(key);
943
972
  }
944
973
  return expired;
945
974
  }
@@ -955,6 +984,29 @@ var TriggerCooldown = class {
955
984
 
956
985
  // src/trigger/engine.ts
957
986
  var SCHEDULER_TICK_MS = 1e3;
987
+ function collectImageUrls(entries) {
988
+ return entries.flatMap((entry) => entry?.role === "user" ? entry.imageUrls ?? [] : []);
989
+ }
990
+ __name(collectImageUrls, "collectImageUrls");
991
+ function sessionIdentityOf(session) {
992
+ return {
993
+ platform: session.platform,
994
+ selfId: session.selfId,
995
+ channelId: session.channelId,
996
+ guildId: session.guildId ?? session.channelId,
997
+ userId: session.userId
998
+ };
999
+ }
1000
+ __name(sessionIdentityOf, "sessionIdentityOf");
1001
+ function poolIdentityOf(channel) {
1002
+ return {
1003
+ platform: channel.platform,
1004
+ selfId: channel.selfId,
1005
+ channelId: channel.channelId,
1006
+ guildId: channel.guildId
1007
+ };
1008
+ }
1009
+ __name(poolIdentityOf, "poolIdentityOf");
958
1010
  var TriggerEngine = class {
959
1011
  constructor(ctx, runner, history, rules, cooldown) {
960
1012
  this.ctx = ctx;
@@ -991,6 +1043,7 @@ var TriggerEngine = class {
991
1043
  if (this.ctx.bots[session.uid]) return next();
992
1044
  if (!session.channelId) return next();
993
1045
  const channelKey = channelKeyOf(session.platform, session.channelId);
1046
+ this.tickCooldown(channelKey);
994
1047
  const entry = buildHistoryEntry(session);
995
1048
  if (entry) {
996
1049
  this.history.recordEntry(channelKey, entry);
@@ -1006,13 +1059,14 @@ var TriggerEngine = class {
1006
1059
  }
1007
1060
  }
1008
1061
  return next(async (nextMiddleware) => {
1062
+ this.tickCooldown(channelKey);
1009
1063
  const matched = this.matchRule(session);
1010
1064
  if (!matched) return nextMiddleware();
1011
1065
  if (this.cooldown.isCooling(channelKey, matched.rule.name, "message", Date.now())) {
1012
1066
  this.cooldown.bufferMessage(channelKey, matched.rule.name, {
1013
1067
  session,
1014
1068
  entry,
1015
- contentText: matched.condition.queryText
1069
+ contentText: matched.condition.contentText
1016
1070
  });
1017
1071
  this.cooldown.extend(channelKey, matched.rule.name, "message", Date.now());
1018
1072
  return;
@@ -1043,32 +1097,24 @@ var TriggerEngine = class {
1043
1097
  this.beginCooldown(channelKeyOf(session.platform, session.channelId), rule.name, "message");
1044
1098
  this.run({
1045
1099
  session,
1046
- platform: session.platform,
1047
- selfId: session.selfId,
1048
- channelId: session.channelId,
1049
- guildId: session.guildId ?? session.channelId,
1050
- userId: session.userId,
1100
+ ...sessionIdentityOf(session),
1051
1101
  triggerType: condition.type,
1052
1102
  rule,
1053
1103
  // query 为该条消息的统一格式单行;纯 @ / 纯引用等无文本消息 entry 为空,由请求器占位兜底
1054
1104
  queryText: entry ? formatHistoryEntry(entry) : "",
1055
- contentText: condition.queryText
1105
+ contentText: condition.contentText
1056
1106
  });
1057
1107
  }
1058
1108
  fireDensity(fire, session) {
1059
1109
  this.beginCooldown(channelKeyOf(session.platform, session.channelId), fire.rule.name, densityLane(fire.conditionIndex));
1060
- const windowText = formatHistoryEntries(fire.entries);
1061
1110
  this.run({
1062
1111
  session,
1063
- platform: session.platform,
1064
- selfId: session.selfId,
1065
- channelId: session.channelId,
1066
- guildId: session.guildId ?? session.channelId,
1067
- userId: session.userId,
1112
+ ...sessionIdentityOf(session),
1068
1113
  triggerType: "density",
1069
1114
  rule: fire.rule,
1070
- queryText: windowText,
1071
- contentText: windowText
1115
+ queryText: formatHistoryEntries(fire.entries),
1116
+ contentText: formatHistoryBrief(fire.entries),
1117
+ imageUrls: collectImageUrls(fire.entries)
1072
1118
  });
1073
1119
  }
1074
1120
  /** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
@@ -1079,30 +1125,37 @@ var TriggerEngine = class {
1079
1125
  this.beginCooldown(channelKeyOf(first.session.platform, first.session.channelId), rule.name, "message");
1080
1126
  this.run({
1081
1127
  session: first.session,
1082
- platform: first.session.platform,
1083
- selfId: first.session.selfId,
1084
- channelId: first.session.channelId,
1085
- guildId: first.session.guildId ?? first.session.channelId,
1086
- userId: first.session.userId,
1128
+ ...sessionIdentityOf(first.session),
1087
1129
  triggerType: "merged",
1088
1130
  rule,
1089
1131
  queryText,
1090
- contentText
1132
+ contentText,
1133
+ // 补齐缓冲内非首条消息的图片(首条由 session 元素提取,重复由上传入口去重)
1134
+ imageUrls: collectImageUrls(buffer.map((item) => item.entry))
1091
1135
  });
1092
1136
  }
1093
1137
  /** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
1094
- fireDensityExpiry(rule, conditionIndex, channel) {
1138
+ fireDensityExpiry(rule, conditionIndex, channel, now) {
1095
1139
  this.beginCooldown(channelKeyOf(channel.platform, channel.channelId), rule.name, densityLane(conditionIndex));
1096
- const windowText = formatHistoryEntries(channel.entries);
1140
+ const condition = rule.conditions[conditionIndex];
1141
+ const windowMs = condition.type === "density" ? condition.densityWindowSeconds * 1e3 : 0;
1142
+ let userId;
1143
+ for (let i = channel.entries.length - 1; i >= 0; i--) {
1144
+ const item = channel.entries[i];
1145
+ if (item.role === "user" && now - item.timestamp <= windowMs) {
1146
+ userId = item.userId;
1147
+ break;
1148
+ }
1149
+ }
1097
1150
  this.run({
1098
- platform: channel.platform,
1099
- selfId: channel.selfId,
1100
- channelId: channel.channelId,
1101
- guildId: channel.guildId,
1151
+ ...poolIdentityOf(channel),
1152
+ userId,
1102
1153
  triggerType: "density",
1103
1154
  rule,
1104
- queryText: windowText,
1105
- contentText: windowText
1155
+ queryText: formatHistoryEntries(channel.entries),
1156
+ contentText: formatHistoryBrief(channel.entries),
1157
+ imageUrls: collectImageUrls(channel.entries),
1158
+ isDirect: channel.isDirect
1106
1159
  });
1107
1160
  }
1108
1161
  /**
@@ -1111,24 +1164,22 @@ var TriggerEngine = class {
1111
1164
  */
1112
1165
  fireSchedule(rule) {
1113
1166
  for (const channel of this.pool.takeSchedule(rule)) {
1114
- const windowText = formatHistoryEntries(channel.entries);
1115
1167
  this.run({
1116
- platform: channel.platform,
1117
- selfId: channel.selfId,
1118
- channelId: channel.channelId,
1119
- guildId: channel.guildId,
1168
+ ...poolIdentityOf(channel),
1120
1169
  triggerType: "schedule",
1121
1170
  rule,
1122
- queryText: windowText,
1123
- contentText: windowText
1171
+ queryText: formatHistoryEntries(channel.entries),
1172
+ contentText: formatHistoryBrief(channel.entries),
1173
+ imageUrls: collectImageUrls(channel.entries),
1174
+ isDirect: channel.isDirect
1124
1175
  });
1125
1176
  }
1126
1177
  }
1127
- /** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
1128
- tickCooldown() {
1178
+ /** 扫描冷却到期(带频道只扫该频道,消息路径用;每秒 interval 全量扫描):消息通道合并触发,密度通道复查池 */
1179
+ tickCooldown(channelKey) {
1129
1180
  if (!this.cooldown.enabled) return;
1130
1181
  const now = Date.now();
1131
- for (const expired of this.cooldown.collectExpired(now)) {
1182
+ for (const expired of this.cooldown.collectExpired(now, channelKey)) {
1132
1183
  this.onCooldownExpired(expired, now);
1133
1184
  }
1134
1185
  }
@@ -1139,11 +1190,11 @@ var TriggerEngine = class {
1139
1190
  if (expired.buffer.length) this.fireMerged(rule, expired.buffer);
1140
1191
  return;
1141
1192
  }
1142
- const index = Number(expired.lane.slice("density:".length));
1193
+ const index = Number(expired.lane.slice(DENSITY_LANE_PREFIX.length));
1143
1194
  const condition = rule.conditions[index];
1144
1195
  if (condition?.type !== "density") return;
1145
1196
  const channel = this.pool.takeDensityChannel(rule, condition, expired.channelKey, now);
1146
- if (channel) this.fireDensityExpiry(rule, index, channel);
1197
+ if (channel) this.fireDensityExpiry(rule, index, channel, now);
1147
1198
  }
1148
1199
  };
1149
1200
 
@@ -1235,11 +1286,26 @@ function extractFileCandidates(session) {
1235
1286
  return candidates;
1236
1287
  }
1237
1288
  __name(extractFileCandidates, "extractFileCandidates");
1289
+ function percentDecodeBytes(input) {
1290
+ const bytes = [];
1291
+ for (let i = 0; i < input.length; i++) {
1292
+ const char = input[i];
1293
+ if (char === "%" && /^[0-9a-fA-F]{2}$/.test(input.slice(i + 1, i + 3))) {
1294
+ bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
1295
+ i += 2;
1296
+ } else {
1297
+ bytes.push(char.charCodeAt(0) & 255);
1298
+ }
1299
+ }
1300
+ return Buffer.from(bytes);
1301
+ }
1302
+ __name(percentDecodeBytes, "percentDecodeBytes");
1238
1303
  function parseDataUrl(source) {
1239
- const match = source.match(/^data:([^;]+);base64,(.+)$/);
1304
+ const match = source.match(/^data:([^;,]*)((?:;[^;,]*)*)?,(.+)$/);
1240
1305
  if (!match) return null;
1241
- const mimeType = match[1];
1242
- const buffer = Buffer.from(match[2], "base64");
1306
+ const mimeType = match[1] || "application/octet-stream";
1307
+ const isBase64 = /(?:^|;)base64/.test(match[2] ?? "");
1308
+ const buffer = isBase64 ? Buffer.from(match[3], "base64") : percentDecodeBytes(match[3]);
1243
1309
  return { buffer, fileName: `file.${guessExtension(mimeType)}`, mimeType };
1244
1310
  }
1245
1311
  __name(parseDataUrl, "parseDataUrl");
@@ -1313,19 +1379,28 @@ async function uploadOne(ctx, client, candidate, difyUser, limit) {
1313
1379
  }
1314
1380
  }
1315
1381
  __name(uploadOne, "uploadOne");
1316
- async function uploadMessageFiles(ctx, client, session, difyUser, limit) {
1317
- const candidates = extractFileCandidates(session);
1318
- const limited = limit.maxCount > 0 ? candidates.slice(0, limit.maxCount) : candidates;
1319
- const dropped = candidates.slice(limited.length).map((candidate) => candidate.source);
1382
+ async function uploadCandidates(ctx, client, candidates, difyUser, limit) {
1383
+ const seen = /* @__PURE__ */ new Set();
1384
+ const unique = candidates.filter((candidate) => {
1385
+ if (seen.has(candidate.source)) return false;
1386
+ seen.add(candidate.source);
1387
+ return true;
1388
+ });
1389
+ const limited = limit.maxCount > 0 ? unique.slice(0, limit.maxCount) : unique;
1390
+ const dropped = [];
1391
+ const markDropped = /* @__PURE__ */ __name((source) => {
1392
+ if (!dropped.includes(source)) dropped.push(source);
1393
+ }, "markDropped");
1394
+ unique.slice(limited.length).forEach((candidate) => markDropped(candidate.source));
1320
1395
  const files = [];
1321
1396
  for (const candidate of limited) {
1322
1397
  const uploaded = await uploadOne(ctx, client, candidate, difyUser, limit);
1323
1398
  if (uploaded) files.push(uploaded);
1324
- else dropped.push(candidate.source);
1399
+ else markDropped(candidate.source);
1325
1400
  }
1326
1401
  return { files, dropped };
1327
1402
  }
1328
- __name(uploadMessageFiles, "uploadMessageFiles");
1403
+ __name(uploadCandidates, "uploadCandidates");
1329
1404
 
1330
1405
  // src/requester/variables.ts
1331
1406
  function resolveSessionField(key, fireCtx, now) {
@@ -1354,7 +1429,7 @@ function resolveSessionField(key, fireCtx, now) {
1354
1429
  case "timestamp":
1355
1430
  return now;
1356
1431
  case "isDirect":
1357
- return session?.isDirect ?? false;
1432
+ return session?.isDirect ?? fireCtx.isDirect ?? false;
1358
1433
  default:
1359
1434
  return "";
1360
1435
  }
@@ -1436,7 +1511,7 @@ ${line}` : line;
1436
1511
  __name(splitText, "splitText");
1437
1512
  function renderReply(input) {
1438
1513
  const messages = [];
1439
- const segments = splitText(input.text, input.config.maxSegmentLength ?? 0);
1514
+ const segments = splitText(input.text, input.config.maxSegmentLength);
1440
1515
  segments.forEach((segment, index) => {
1441
1516
  const elements = [];
1442
1517
  if (index === 0 && input.quoteMessageId) {
@@ -1492,6 +1567,7 @@ function extractTextOutputs(outputs) {
1492
1567
  __name(extractTextOutputs, "extractTextOutputs");
1493
1568
  function parseWorkflowResponse(response) {
1494
1569
  const data = response.data;
1570
+ if (!data) throw new DifyApiError("workflow 响应缺少 data", 200);
1495
1571
  if (data.error) throw new DifyApiError(data.error, 200);
1496
1572
  if (data.status && !SUCCESS_STATUSES.includes(data.status)) {
1497
1573
  throw new DifyApiError(`workflow ${data.status}`, 200);
@@ -1534,11 +1610,13 @@ var RequestRunner = class {
1534
1610
  uploadedFiles: []
1535
1611
  };
1536
1612
  const upload = resolveUploadConfig(this.config);
1537
- if (upload.enabled && fireCtx.session) {
1538
- const { files, dropped } = await uploadMessageFiles(
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(
1539
1617
  this.ctx,
1540
1618
  client,
1541
- fireCtx.session,
1619
+ [...sessionCandidates, ...urlCandidates],
1542
1620
  requestCtx.difyUser,
1543
1621
  { maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
1544
1622
  );
@@ -1562,17 +1640,20 @@ var RequestRunner = class {
1562
1640
  this.ctx.logger.warn("规则 %s 的请求未返回任何内容", rule.name);
1563
1641
  return;
1564
1642
  }
1565
- const quote = resolveQuoteConfig(this.config);
1566
- const elapsedSeconds = (Date.now() - startedAt) / 1e3;
1567
- const shouldQuote = quote.enabled && elapsedSeconds >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
1568
1643
  const messages = renderReply({
1569
1644
  text: reply.text,
1570
1645
  files: reply.files,
1571
- quoteMessageId: shouldQuote ? fireCtx.session?.messageId : void 0,
1646
+ quoteMessageId: this.shouldQuoteReply(fireCtx, startedAt) ? fireCtx.session?.messageId : void 0,
1572
1647
  config: this.config
1573
1648
  });
1574
1649
  await this.deliver(fireCtx, messages);
1575
1650
  }
1651
+ /** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
1652
+ shouldQuoteReply(fireCtx, startedAt) {
1653
+ const quote = resolveQuoteConfig(this.config);
1654
+ if (!quote.enabled) return false;
1655
+ return (Date.now() - startedAt) / 1e3 >= quote.minElapsedSeconds && isMessageTrigger(fireCtx.triggerType);
1656
+ }
1576
1657
  /** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
1577
1658
  async dispatch(app, client, fireCtx, requestCtx) {
1578
1659
  const base = {
@@ -1610,7 +1691,8 @@ var RequestRunner = class {
1610
1691
  }
1611
1692
  return;
1612
1693
  }
1613
- const bot = Object.values(this.ctx.bots).find((b) => b.platform === fireCtx.platform);
1694
+ const bots = Object.values(this.ctx.bots);
1695
+ const bot = bots.find((b) => b.platform === fireCtx.platform && b.selfId === fireCtx.selfId) ?? bots.find((b) => b.platform === fireCtx.platform);
1614
1696
  if (!bot) {
1615
1697
  this.ctx.logger.warn("平台 %s 上没有可用 bot,无法发送主动消息", fireCtx.platform);
1616
1698
  return;
@@ -1632,7 +1714,7 @@ var ConversationStore = class {
1632
1714
  }
1633
1715
  /** 粒度归一:bindingKey 与 difyUser 共用,保证两者始终对齐 */
1634
1716
  resolveScopeDimension(rule, fireCtx) {
1635
- if (fireCtx.session?.isDirect || rule.conversationScope === "per-user") return "user";
1717
+ if (this.isDirectOf(fireCtx) || rule.conversationScope === "per-user") return "user";
1636
1718
  switch (rule.conversationScope) {
1637
1719
  case "global":
1638
1720
  return "global";
@@ -1642,13 +1724,17 @@ var ConversationStore = class {
1642
1724
  return "guild";
1643
1725
  }
1644
1726
  }
1727
+ /** 私聊判定:有 session 读 session,无 session 的主动触发读池透传的标记 */
1728
+ isDirectOf(fireCtx) {
1729
+ return fireCtx.session?.isDirect ?? fireCtx.isDirect ?? false;
1730
+ }
1645
1731
  /** bindingKey = 应用 ID + 粒度 + 平台身份 */
1646
1732
  computeBindingKey(appId, rule, fireCtx) {
1647
1733
  const base = `${appId}:${fireCtx.platform}:${fireCtx.selfId}`;
1648
1734
  switch (this.resolveScopeDimension(rule, fireCtx)) {
1649
1735
  // 群内按「群+用户」区分;私聊用 private 段占位
1650
1736
  case "user":
1651
- return fireCtx.session?.isDirect ? `${base}:private:${fireCtx.userId ?? fireCtx.channelId}` : `${base}:user:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1737
+ return this.isDirectOf(fireCtx) ? `${base}:private:${fireCtx.userId ?? fireCtx.channelId}` : `${base}:user:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1652
1738
  case "guild":
1653
1739
  return `${base}:guild:${fireCtx.guildId ?? fireCtx.channelId}`;
1654
1740
  case "channel":
@@ -1665,7 +1751,7 @@ var ConversationStore = class {
1665
1751
  computeDifyUser(rule, fireCtx) {
1666
1752
  switch (this.resolveScopeDimension(rule, fireCtx)) {
1667
1753
  case "user":
1668
- return fireCtx.session?.isDirect ? `koishi:${fireCtx.platform}:private:${fireCtx.userId ?? fireCtx.channelId}` : `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1754
+ return this.isDirectOf(fireCtx) ? `koishi:${fireCtx.platform}:private:${fireCtx.userId ?? fireCtx.channelId}` : `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1669
1755
  case "guild":
1670
1756
  return `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}`;
1671
1757
  case "channel":
@@ -1705,16 +1791,25 @@ var ConversationStore = class {
1705
1791
  /**
1706
1792
  * 重置匹配的绑定:本地删行 + 尽力删除 Dify 侧会话(失败仅记录)。
1707
1793
  * 表已无 guildKey 列,按频道匹配时全表读回后解析 bindingKey 过滤(行数量级很小)。
1794
+ * 覆盖 guild / user(群内)/ channel 段与私聊执行时的 private 段;
1795
+ * global 段跨频道,不随单频道重置。
1708
1796
  */
1709
1797
  async resetWhere(filter) {
1710
1798
  const rows = await this.ctx.database.get("dify_conversation", {});
1799
+ const splitKey = /* @__PURE__ */ __name((key) => {
1800
+ const separator = key.indexOf(":");
1801
+ return separator < 0 ? [key, ""] : [key.slice(0, separator), key.slice(separator + 1)];
1802
+ }, "splitKey");
1803
+ const [guildPlatform, guildId] = splitKey(filter.guildKey ?? "");
1804
+ const [channelPlatform, channelId] = splitKey(filter.channelKey ?? "");
1711
1805
  const matched = rows.filter((row) => {
1712
1806
  if (filter.bindingKey) return row.bindingKey === filter.bindingKey;
1713
1807
  const { platform, scope, ids } = this.parseBindingKey(row.bindingKey);
1714
- const [guildPlatform, guildId] = (filter.guildKey ?? "").split(":");
1715
1808
  if (platform !== guildPlatform) return false;
1716
1809
  if (scope === "guild") return ids[0] === guildId;
1717
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;
1718
1813
  return false;
1719
1814
  });
1720
1815
  let reset = 0;
@@ -1757,8 +1852,12 @@ __name(defineTables, "defineTables");
1757
1852
  // src/commands/misc.ts
1758
1853
  function registerMiscCommands(parent, conversationStore) {
1759
1854
  parent.subcommand(".conv.reset", { authority: 3 }).action(async ({ session }) => {
1760
- const guildKey = guildKeyOf(session.platform, session.guildId, session.channelId);
1761
- const reset = await conversationStore.resetWhere({ guildKey });
1855
+ const reset = await conversationStore.resetWhere({
1856
+ guildKey: guildKeyOf(session.platform, session.guildId, session.channelId),
1857
+ channelKey: channelKeyOf(session.platform, session.channelId),
1858
+ isDirect: session.isDirect,
1859
+ userId: session.userId
1860
+ });
1762
1861
  if (!reset) return session.text("dify-ai.commands.conv-empty");
1763
1862
  return session.text("dify-ai.commands.conv-reset", [reset]);
1764
1863
  });
@@ -1768,6 +1867,7 @@ __name(registerMiscCommands, "registerMiscCommands");
1768
1867
  // src/index.ts
1769
1868
  var name = "dify-ai";
1770
1869
  var inject = { required: ["database"] };
1870
+ var PROBE_TIMEOUT_MS = 1e4;
1771
1871
  function apply(ctx, config) {
1772
1872
  ctx.i18n.define("zh-CN", zh_CN_default);
1773
1873
  ctx.i18n.define("en-US", en_US_default);
@@ -1778,18 +1878,20 @@ function apply(ctx, config) {
1778
1878
  const conversationStore = new ConversationStore(ctx, apps);
1779
1879
  const runner = new RequestRunner(ctx, config, conversationStore, history, apps, appTypes);
1780
1880
  const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
1781
- ctx.on("ready", async () => {
1782
- await probeAppTypes(ctx, config, apps, appTypes);
1881
+ ctx.on("ready", () => {
1783
1882
  engine.start();
1883
+ void probeAppTypes(ctx, apps, appTypes);
1784
1884
  });
1785
1885
  const admin = ctx.command("dify", { authority: 3 });
1786
1886
  registerMiscCommands(admin, conversationStore);
1787
1887
  }
1788
1888
  __name(apply, "apply");
1789
- async function probeAppTypes(ctx, config, apps, appTypes) {
1889
+ async function probeAppTypes(ctx, apps, appTypes) {
1890
+ const autoApps = [...apps.values()].filter((app) => app.appType === "auto");
1891
+ if (!autoApps.length) return;
1790
1892
  await Promise.all(
1791
- [...apps.values()].filter((app) => app.appType === "auto").map(async (app) => {
1792
- const client = createDifyClient(ctx, app, config.requestTimeout);
1893
+ autoApps.map(async (app) => {
1894
+ const client = createDifyClient(ctx, app, PROBE_TIMEOUT_MS);
1793
1895
  if (!client) return;
1794
1896
  try {
1795
1897
  const info = await client.getAppInfo();
@@ -27,6 +27,6 @@ export interface UploadResult {
27
27
  files: DifyInputFileObject[];
28
28
  dropped: string[];
29
29
  }
30
- /** 提取并上传消息中的文件(先按数量上限截断候选,再逐个上传) */
31
- export declare function uploadMessageFiles(ctx: Context, client: DifyClient, session: Session, difyUser: string, limit: UploadLimit): Promise<UploadResult>;
30
+ /** 上传一批候选(入口按 type:source 去重,再按数量上限截断、逐个上传) */
31
+ export declare function uploadCandidates(ctx: Context, client: DifyClient, candidates: FileCandidate[], difyUser: string, limit: UploadLimit): Promise<UploadResult>;
32
32
  export {};
@@ -28,6 +28,8 @@ export declare class RequestRunner {
28
28
  /** 应用类型:schema 显式指定优先,auto 时用探测结果(未探测到回退 chat) */
29
29
  private appTypeOf;
30
30
  run(fireCtx: FireContext): Promise<void>;
31
+ /** 引用判定:开关开启、回复耗时达到阈值、且由消息触发(定时与密度没有单一触发消息) */
32
+ private shouldQuoteReply;
31
33
  /** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
32
34
  private dispatch;
33
35
  private deliver;
@@ -15,6 +15,8 @@ export declare class ConversationStore {
15
15
  apps: Map<string, AppConfigEntry>);
16
16
  /** 粒度归一:bindingKey 与 difyUser 共用,保证两者始终对齐 */
17
17
  private resolveScopeDimension;
18
+ /** 私聊判定:有 session 读 session,无 session 的主动触发读池透传的标记 */
19
+ private isDirectOf;
18
20
  /** bindingKey = 应用 ID + 粒度 + 平台身份 */
19
21
  computeBindingKey(appId: string, rule: DifyRule, fireCtx: FireContext): string;
20
22
  /**
@@ -32,10 +34,15 @@ export declare class ConversationStore {
32
34
  /**
33
35
  * 重置匹配的绑定:本地删行 + 尽力删除 Dify 侧会话(失败仅记录)。
34
36
  * 表已无 guildKey 列,按频道匹配时全表读回后解析 bindingKey 过滤(行数量级很小)。
37
+ * 覆盖 guild / user(群内)/ channel 段与私聊执行时的 private 段;
38
+ * global 段跨频道,不随单频道重置。
35
39
  */
36
40
  resetWhere(filter: {
37
41
  bindingKey?: string;
38
42
  guildKey?: string;
43
+ channelKey?: string;
44
+ isDirect?: boolean;
45
+ userId?: string;
39
46
  }): Promise<number>;
40
47
  /** 按应用 ID(apps 配置的 appId 字段)反查应用 */
41
48
  private findByAppId;
@@ -2,10 +2,10 @@ import { Session } from 'koishi';
2
2
  import { MessageTriggerType, TriggerCondition, TriggerScope } from '../types';
3
3
  export interface ConditionMatched {
4
4
  type: MessageTriggerType;
5
- /** 触发后用作 content 变量的剥离文本(query 由引擎按统一消息格式另行构造) */
6
- queryText: string;
5
+ /** content 变量的剥离文本,保留 @ 与图片占位(query 由引擎按统一消息格式另行构造) */
6
+ contentText: string;
7
7
  }
8
- /** 评估单个消息条件;返回 null 表示未命中 */
8
+ /** 评估单个消息条件;返回 null 表示未命中(匹配基于纯文本,@ 元素不参与判定) */
9
9
  export declare function evaluateCondition(condition: TriggerCondition, session: Session): ConditionMatched | null;
10
10
  /** 作用域过滤:留空的维度不做限制 */
11
11
  export declare function matchScope(scope: TriggerScope, session: Session): boolean;
@@ -10,6 +10,8 @@ export interface PendingMessage {
10
10
  }
11
11
  /** 冷却通道:message = 规则内全部消息类条件共享;density:N = 第 N 个密度条件独立 */
12
12
  export type CooldownLane = 'message' | `density:${number}`;
13
+ /** 密度通道名的下标前缀 */
14
+ export declare const DENSITY_LANE_PREFIX = "density:";
13
15
  /** 密度条件的通道名 */
14
16
  export declare function densityLane(index: number): CooldownLane;
15
17
  /** 到期扫描交出的通道产物 */
@@ -34,13 +36,15 @@ export declare class TriggerCooldown {
34
36
  /** 触发发起时登记冷却 */
35
37
  begin(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
36
38
  isCooling(channelKey: string, ruleName: string, lane: CooldownLane, now: number): boolean;
37
- /** 滑动窗口顺延:截止时间顺延到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
39
+ /** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
40
+ private slide;
41
+ /** 滑动窗口顺延:已到期的通道不复活 */
38
42
  extend(channelKey: string, ruleName: string, lane: CooldownLane, now: number): void;
39
- /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
43
+ /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
40
44
  extendDensityLanes(channelKey: string, now: number): void;
41
45
  /** 冷却期内缓存一条命中消息(仅消息通道) */
42
46
  bufferMessage(channelKey: string, ruleName: string, message: PendingMessage): void;
43
- /** 到期扫描:取走全部到期通道(连缓冲一并交出),由调用方决定是否重新登记 */
44
- collectExpired(now: number): ExpiredCooldown[];
47
+ /** 到期扫描:取走到期通道(连缓冲一并交出),由调用方决定是否重新登记;限定频道时只扫该频道 */
48
+ collectExpired(now: number, channelKey?: string): ExpiredCooldown[];
45
49
  private lanesOf;
46
50
  }
@@ -33,7 +33,7 @@ export declare class TriggerEngine {
33
33
  * 频道身份取自池内最近一条消息;无消息的频道自然跳过。定时不受冷却影响。
34
34
  */
35
35
  private fireSchedule;
36
- /** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
36
+ /** 扫描冷却到期(带频道只扫该频道,消息路径用;每秒 interval 全量扫描):消息通道合并触发,密度通道复查池 */
37
37
  private tickCooldown;
38
38
  private onCooldownExpired;
39
39
  }
package/lib/types.d.ts CHANGED
@@ -110,6 +110,10 @@ export interface FireContext {
110
110
  rule: DifyRule;
111
111
  /** 发给 Dify 的 query:统一消息格式(消息触发为单行,窗口触发为逐行拼接) */
112
112
  queryText: string;
113
- /** content 变量的值:消息触发为剥离文本,窗口触发为统一格式拼接 */
113
+ /** content 变量的值:消息触发为剥离文本(剔除 @bot),窗口触发为精简行拼接 */
114
114
  contentText: string;
115
+ /** 本次触发相关消息的图片 URL(合并/密度/定时的池或缓冲条目合集):供上传管线补齐非首条消息的图片 */
116
+ imageUrls?: string[];
117
+ /** 无 session 的主动触发(定时/密度到期)从消息池透传的私聊标记:会话绑定与 isDirect 变量用 */
118
+ isDirect?: boolean;
115
119
  }
@@ -1,4 +1,6 @@
1
1
  import { Session } from 'koishi';
2
+ /** 图片占位符携带的 URL 摘要长度(hex 位) */
3
+ export declare const IMAGE_HASH_LENGTH = 8;
2
4
  /**
3
5
  * 会话文本提取的统一入口。此前有四个变体(条件匹配、历史记录、变量、@ 剥离)
4
6
  * 各自实现「elements 有则取 text 元素,否则解析 content」,trim 与解析行为已经漂移,
@@ -6,21 +8,24 @@ import { Session } from 'koishi';
6
8
  */
7
9
  /** 把可解析的消息文本(含元素标记)转纯文本;解析失败回退原文 */
8
10
  export declare function parseTextSafe(content: string | undefined): string;
9
- interface ExtractTextOptions {
10
- /** 去掉 @ 元素(at-bot 触发的 query 语义) */
11
- excludeAt?: boolean;
12
- }
13
- /** 会话消息纯文本:优先 elements,退回 content 解析;统一 trim */
14
- export declare function extractText(session: Session, options?: ExtractTextOptions): string;
11
+ /** 会话消息纯文本:优先 elements,退回 content 解析;统一 trim(@ 元素不产生文本,仅供条件匹配) */
12
+ export declare function extractText(session: Session): string;
15
13
  /** 消息中的图片元素在文本形态里的占位符(图片无 URL 时的兜底形态) */
16
14
  export declare const IMAGE_PLACEHOLDER = "[\u56FE\u7247]";
17
- /** 图片占位符带 URL 摘要前 8 位:同 URL 同标识,query 里的占位符与上传文件可全局对应 */
15
+ /** 图片占位符带 URL 摘要:同 URL 同标识,query 里的占位符与上传文件可全局对应 */
18
16
  export declare function imagePlaceholder(url?: string): string;
19
17
  /** 给被丢弃图片的占位符追加已丢弃注记:按 URL 摘要全局定位,无需与元素顺序对齐 */
20
18
  export declare function annotateDroppedPlaceholders(text: string, droppedUrls: string[]): string;
19
+ interface ExtractVerbatimOptions {
20
+ /** 跳过 @ 机器人自身的渲染(content 变量语义:@bot 是触发结构而非消息内容) */
21
+ excludeBotAt?: boolean;
22
+ }
21
23
  /**
22
- * 带媒体占位符的消息文本:text 元素按序拼接,img/image 元素渲染为 [image:URL摘要],
23
- * 供聊天记录呈现(历史消息变量 / 定时摘要 / 密度触发共用)。
24
+ * 消息原貌文本:text 元素按序拼接,@ 元素渲染为 @名字(缺名字用 ID),
25
+ * img/image 元素渲染为 [image:URL摘要]——统一格式行(query/池/历史)用默认形态,
26
+ * content 变量通道用 excludeBotAt 形态剔除 @bot。
24
27
  */
25
- export declare function extractTextWithPlaceholders(session: Session): string;
28
+ export declare function extractTextWithPlaceholders(session: Session, options?: ExtractVerbatimOptions): string;
29
+ /** 消息中图片元素的 URL 列表(按元素序,与原貌文本中 [image:摘要] 的出现序一致) */
30
+ export declare function extractImageUrls(session: Session): string[];
26
31
  export {};
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.5",
4
+ "version": "0.1.7",
5
5
  "author": "PinkElysia",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/PinkElysiaDev/dify-ai-plugin-for-Koishi#readme",