koishi-plugin-dify-ai 0.1.6 → 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.
Files changed (2) hide show
  1. package/lib/index.js +238 -140
  2. package/package.json +1 -1
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 {
@@ -519,7 +517,7 @@ __name(extractText, "extractText");
519
517
  var IMAGE_PLACEHOLDER = "[图片]";
520
518
  function imagePlaceholder(url) {
521
519
  if (!url) return IMAGE_PLACEHOLDER;
522
- 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)}]`;
523
521
  }
524
522
  __name(imagePlaceholder, "imagePlaceholder");
525
523
  function annotateDroppedPlaceholders(text, droppedUrls) {
@@ -531,20 +529,23 @@ function annotateDroppedPlaceholders(text, droppedUrls) {
531
529
  return result;
532
530
  }
533
531
  __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
- })();
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 = {}) {
542
542
  const parts = [];
543
- for (const element of elements) {
543
+ for (const element of elementsOf(session)) {
544
544
  if (element.type === "text") {
545
545
  const text = element.attrs?.["content"] ?? "";
546
546
  if (text) parts.push(text);
547
547
  } else if (element.type === "at") {
548
+ if (options.excludeBotAt && element.attrs?.["id"] === session.selfId) continue;
548
549
  const name2 = element.attrs?.["name"] ?? element.attrs?.["id"];
549
550
  if (name2) parts.push(`@${name2}`);
550
551
  } else if (element.type === "img" || element.type === "image") {
@@ -554,6 +555,16 @@ function extractTextWithPlaceholders(session) {
554
555
  return parts.join(" ").replace(/\s+/g, " ").trim();
555
556
  }
556
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");
557
568
 
558
569
  // src/history.ts
559
570
  var HISTORY_CAPACITY_MULTIPLIER = 2;
@@ -575,7 +586,7 @@ function formatHistoryEntries(entries) {
575
586
  }
576
587
  __name(formatHistoryEntries, "formatHistoryEntries");
577
588
  function formatHistoryBrief(entries) {
578
- return entries.map((entry) => entry.messageId ? `[msg:${entry.messageId}] ${entry.content}` : entry.content).join("\n");
589
+ return entries.map((entry) => entry.messageId ? `[msg:${entry.messageId}] ${entry.contentText}` : entry.contentText).join("\n");
579
590
  }
580
591
  __name(formatHistoryBrief, "formatHistoryBrief");
581
592
  function buildQuote(session) {
@@ -592,12 +603,15 @@ __name(buildQuote, "buildQuote");
592
603
  function buildHistoryEntry(session, role = "user") {
593
604
  const content = extractTextWithPlaceholders(session);
594
605
  if (!content) return null;
606
+ const imageUrls = extractImageUrls(session);
595
607
  return {
596
608
  role,
597
609
  userId: role === "assistant" ? session.selfId : session.userId ?? "未知用户",
598
- username: role === "assistant" ? "你自己的发言" : session.username || session.userId || "用户",
610
+ username: role === "assistant" ? "你自己的发言" : session.username || session.userId,
599
611
  messageId: session.messageId,
600
612
  content,
613
+ contentText: extractTextWithPlaceholders(session, { excludeBotAt: true }),
614
+ ...imageUrls.length ? { imageUrls } : {},
601
615
  timestamp: session.timestamp ?? Date.now(),
602
616
  quote: role === "user" ? buildQuote(session) : void 0
603
617
  };
@@ -635,41 +649,49 @@ function hasAt(session, id) {
635
649
  return session.elements?.some((element) => element.type === "at" && element.attrs?.["id"] === id) ?? false;
636
650
  }
637
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");
638
660
  function evaluateCondition(condition, session) {
639
661
  const text = extractText(session);
640
- const verbatim = extractTextWithPlaceholders(session);
662
+ const verbatim = extractTextWithPlaceholders(session, { excludeBotAt: true });
641
663
  switch (condition.type) {
642
664
  case "keyword-prefix": {
643
665
  const keyword = condition.keywords.find((k) => k && text.startsWith(k));
644
666
  if (!keyword) return null;
645
- const rest = text.slice(keyword.length).trim();
646
- return { type: "keyword-prefix", queryText: rest || verbatim };
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 };
647
669
  }
648
670
  case "keyword-contains": {
649
671
  const keyword = condition.keywords.find((k) => k && text.includes(k));
650
672
  if (!keyword) return null;
651
- return { type: "keyword-contains", queryText: verbatim };
673
+ return { type: "keyword-contains", contentText: verbatim };
652
674
  }
653
675
  case "at-bot": {
654
676
  const botId = session.bot?.userId;
655
677
  if (!botId) return null;
656
678
  if (!session.stripped?.atSelf && !hasAt(session, botId)) return null;
657
- return { type: "at-bot", queryText: verbatim };
679
+ return { type: "at-bot", contentText: verbatim };
658
680
  }
659
681
  case "at-user": {
660
682
  const isAtAnyUser = condition.userIds.some((id) => hasAt(session, id));
661
683
  if (!isAtAnyUser) return null;
662
- return { type: "at-user", queryText: verbatim };
684
+ return { type: "at-user", contentText: verbatim };
663
685
  }
664
686
  case "quote-bot": {
665
687
  const botId = session.bot?.userId;
666
688
  if (!botId || session.quote?.user?.id !== botId) return null;
667
- return { type: "quote-bot", queryText: verbatim };
689
+ return { type: "quote-bot", contentText: verbatim };
668
690
  }
669
691
  case "quote-user": {
670
692
  const author = session.quote?.user?.id;
671
693
  if (!author || !condition.userIds.includes(author)) return null;
672
- return { type: "quote-user", queryText: verbatim };
694
+ return { type: "quote-user", contentText: verbatim };
673
695
  }
674
696
  default:
675
697
  return null;
@@ -808,13 +830,7 @@ var RuleScheduler = class {
808
830
  }
809
831
  entries = [];
810
832
  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;
833
+ this.entries = rules.map((rule) => ({ rule, nextRunAt: this.computeNextRun(rule) })).filter((entry) => entry.nextRunAt != null);
818
834
  }
819
835
  tick() {
820
836
  const now = Date.now();
@@ -864,8 +880,9 @@ var RuleScheduler = class {
864
880
 
865
881
  // src/trigger/cooldown.ts
866
882
  var BUFFER_CAPACITY = 1024;
883
+ var DENSITY_LANE_PREFIX = "density:";
867
884
  function densityLane(index) {
868
- return `density:${index}`;
885
+ return `${DENSITY_LANE_PREFIX}${index}`;
869
886
  }
870
887
  __name(densityLane, "densityLane");
871
888
  function laneKeyOf(ruleName, lane) {
@@ -896,30 +913,33 @@ var TriggerCooldown = class {
896
913
  });
897
914
  }
898
915
  isCooling(channelKey, ruleName, lane, now) {
899
- const state = this.lanesOf(channelKey).get(laneKeyOf(ruleName, lane));
916
+ const state = this.channels.get(channelKey)?.get(laneKeyOf(ruleName, lane));
900
917
  return state != null && now < state.deadline;
901
918
  }
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;
919
+ /** 滑动窗口顺延:截止推到「now + 最小窗口」,不超过「触发时刻 + 最大窗口」 */
920
+ slide(state, setting, now) {
907
921
  state.deadline = Math.min(
908
- Math.max(state.deadline, now + this.setting.minMs),
909
- state.fireAt + this.setting.maxMs
922
+ Math.max(state.deadline, now + setting.minMs),
923
+ state.fireAt + setting.maxMs
910
924
  );
911
925
  }
912
- /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动) */
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
+ /** 该频道所有密度通道由一条过路消息顺延(密度冷却期内的消息积攒驱动窗口滑动);已到期的不复活 */
913
935
  extendDensityLanes(channelKey, now) {
914
- if (this.setting?.mode !== "sliding") return;
936
+ const setting = this.setting;
937
+ if (setting?.mode !== "sliding") return;
915
938
  const lanes = this.channels.get(channelKey);
916
939
  if (!lanes) return;
917
940
  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
- );
941
+ if (!key.includes("\ndensity:") || now >= state.deadline) continue;
942
+ this.slide(state, setting, now);
923
943
  }
924
944
  }
925
945
  /** 冷却期内缓存一条命中消息(仅消息通道) */
@@ -931,22 +951,24 @@ var TriggerCooldown = class {
931
951
  state.buffer.splice(0, state.buffer.length - BUFFER_CAPACITY);
932
952
  }
933
953
  }
934
- /** 到期扫描:取走全部到期通道(连缓冲一并交出),由调用方决定是否重新登记 */
935
- collectExpired(now) {
954
+ /** 到期扫描:取走到期通道(连缓冲一并交出),由调用方决定是否重新登记;限定频道时只扫该频道 */
955
+ collectExpired(now, channelKey) {
936
956
  const expired = [];
937
- for (const [channelKey, lanes] of this.channels) {
938
- 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) {
939
961
  if (now < state.deadline) continue;
940
- const separator = key.indexOf("\n");
941
- lanes.delete(key);
962
+ const separator = laneKey.indexOf("\n");
963
+ lanes.delete(laneKey);
942
964
  expired.push({
943
- channelKey,
944
- ruleName: key.slice(0, separator),
945
- lane: key.slice(separator + 1),
965
+ channelKey: key,
966
+ ruleName: laneKey.slice(0, separator),
967
+ lane: laneKey.slice(separator + 1),
946
968
  buffer: state.buffer
947
969
  });
948
970
  }
949
- if (!lanes.size) this.channels.delete(channelKey);
971
+ if (!lanes.size) this.channels.delete(key);
950
972
  }
951
973
  return expired;
952
974
  }
@@ -962,6 +984,29 @@ var TriggerCooldown = class {
962
984
 
963
985
  // src/trigger/engine.ts
964
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");
965
1010
  var TriggerEngine = class {
966
1011
  constructor(ctx, runner, history, rules, cooldown) {
967
1012
  this.ctx = ctx;
@@ -998,6 +1043,7 @@ var TriggerEngine = class {
998
1043
  if (this.ctx.bots[session.uid]) return next();
999
1044
  if (!session.channelId) return next();
1000
1045
  const channelKey = channelKeyOf(session.platform, session.channelId);
1046
+ this.tickCooldown(channelKey);
1001
1047
  const entry = buildHistoryEntry(session);
1002
1048
  if (entry) {
1003
1049
  this.history.recordEntry(channelKey, entry);
@@ -1013,13 +1059,14 @@ var TriggerEngine = class {
1013
1059
  }
1014
1060
  }
1015
1061
  return next(async (nextMiddleware) => {
1062
+ this.tickCooldown(channelKey);
1016
1063
  const matched = this.matchRule(session);
1017
1064
  if (!matched) return nextMiddleware();
1018
1065
  if (this.cooldown.isCooling(channelKey, matched.rule.name, "message", Date.now())) {
1019
1066
  this.cooldown.bufferMessage(channelKey, matched.rule.name, {
1020
1067
  session,
1021
1068
  entry,
1022
- contentText: matched.condition.queryText
1069
+ contentText: matched.condition.contentText
1023
1070
  });
1024
1071
  this.cooldown.extend(channelKey, matched.rule.name, "message", Date.now());
1025
1072
  return;
@@ -1050,31 +1097,24 @@ var TriggerEngine = class {
1050
1097
  this.beginCooldown(channelKeyOf(session.platform, session.channelId), rule.name, "message");
1051
1098
  this.run({
1052
1099
  session,
1053
- platform: session.platform,
1054
- selfId: session.selfId,
1055
- channelId: session.channelId,
1056
- guildId: session.guildId ?? session.channelId,
1057
- userId: session.userId,
1100
+ ...sessionIdentityOf(session),
1058
1101
  triggerType: condition.type,
1059
1102
  rule,
1060
1103
  // query 为该条消息的统一格式单行;纯 @ / 纯引用等无文本消息 entry 为空,由请求器占位兜底
1061
1104
  queryText: entry ? formatHistoryEntry(entry) : "",
1062
- contentText: condition.queryText
1105
+ contentText: condition.contentText
1063
1106
  });
1064
1107
  }
1065
1108
  fireDensity(fire, session) {
1066
1109
  this.beginCooldown(channelKeyOf(session.platform, session.channelId), fire.rule.name, densityLane(fire.conditionIndex));
1067
1110
  this.run({
1068
1111
  session,
1069
- platform: session.platform,
1070
- selfId: session.selfId,
1071
- channelId: session.channelId,
1072
- guildId: session.guildId ?? session.channelId,
1073
- userId: session.userId,
1112
+ ...sessionIdentityOf(session),
1074
1113
  triggerType: "density",
1075
1114
  rule: fire.rule,
1076
1115
  queryText: formatHistoryEntries(fire.entries),
1077
- contentText: formatHistoryBrief(fire.entries)
1116
+ contentText: formatHistoryBrief(fire.entries),
1117
+ imageUrls: collectImageUrls(fire.entries)
1078
1118
  });
1079
1119
  }
1080
1120
  /** 冷却到期:把缓冲内的触发消息合并为一次触发,session 取首条(供投递与文件上传) */
@@ -1085,29 +1125,37 @@ var TriggerEngine = class {
1085
1125
  this.beginCooldown(channelKeyOf(first.session.platform, first.session.channelId), rule.name, "message");
1086
1126
  this.run({
1087
1127
  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,
1128
+ ...sessionIdentityOf(first.session),
1093
1129
  triggerType: "merged",
1094
1130
  rule,
1095
1131
  queryText,
1096
- contentText
1132
+ contentText,
1133
+ // 补齐缓冲内非首条消息的图片(首条由 session 元素提取,重复由上传入口去重)
1134
+ imageUrls: collectImageUrls(buffer.map((item) => item.entry))
1097
1135
  });
1098
1136
  }
1099
1137
  /** 密度冷却到期复查达标:无当前消息,用池身份构造上下文(投递同定时路径) */
1100
- fireDensityExpiry(rule, conditionIndex, channel) {
1138
+ fireDensityExpiry(rule, conditionIndex, channel, now) {
1101
1139
  this.beginCooldown(channelKeyOf(channel.platform, channel.channelId), rule.name, densityLane(conditionIndex));
1140
+ const condition = rule.conditions[conditionIndex];
1141
+ const windowMs = condition.type === "density" ? condition.densityWindowSeconds * 1e3 : 0;
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
+ }
1102
1150
  this.run({
1103
- platform: channel.platform,
1104
- selfId: channel.selfId,
1105
- channelId: channel.channelId,
1106
- guildId: channel.guildId,
1151
+ ...poolIdentityOf(channel),
1152
+ userId,
1107
1153
  triggerType: "density",
1108
1154
  rule,
1109
1155
  queryText: formatHistoryEntries(channel.entries),
1110
- contentText: formatHistoryBrief(channel.entries)
1156
+ contentText: formatHistoryBrief(channel.entries),
1157
+ imageUrls: collectImageUrls(channel.entries),
1158
+ isDirect: channel.isDirect
1111
1159
  });
1112
1160
  }
1113
1161
  /**
@@ -1117,22 +1165,21 @@ var TriggerEngine = class {
1117
1165
  fireSchedule(rule) {
1118
1166
  for (const channel of this.pool.takeSchedule(rule)) {
1119
1167
  this.run({
1120
- platform: channel.platform,
1121
- selfId: channel.selfId,
1122
- channelId: channel.channelId,
1123
- guildId: channel.guildId,
1168
+ ...poolIdentityOf(channel),
1124
1169
  triggerType: "schedule",
1125
1170
  rule,
1126
1171
  queryText: formatHistoryEntries(channel.entries),
1127
- contentText: formatHistoryBrief(channel.entries)
1172
+ contentText: formatHistoryBrief(channel.entries),
1173
+ imageUrls: collectImageUrls(channel.entries),
1174
+ isDirect: channel.isDirect
1128
1175
  });
1129
1176
  }
1130
1177
  }
1131
- /** 每秒扫描冷却到期:消息通道合并触发,密度通道复查池 */
1132
- tickCooldown() {
1178
+ /** 扫描冷却到期(带频道只扫该频道,消息路径用;每秒 interval 全量扫描):消息通道合并触发,密度通道复查池 */
1179
+ tickCooldown(channelKey) {
1133
1180
  if (!this.cooldown.enabled) return;
1134
1181
  const now = Date.now();
1135
- for (const expired of this.cooldown.collectExpired(now)) {
1182
+ for (const expired of this.cooldown.collectExpired(now, channelKey)) {
1136
1183
  this.onCooldownExpired(expired, now);
1137
1184
  }
1138
1185
  }
@@ -1143,11 +1190,11 @@ var TriggerEngine = class {
1143
1190
  if (expired.buffer.length) this.fireMerged(rule, expired.buffer);
1144
1191
  return;
1145
1192
  }
1146
- const index = Number(expired.lane.slice("density:".length));
1193
+ const index = Number(expired.lane.slice(DENSITY_LANE_PREFIX.length));
1147
1194
  const condition = rule.conditions[index];
1148
1195
  if (condition?.type !== "density") return;
1149
1196
  const channel = this.pool.takeDensityChannel(rule, condition, expired.channelKey, now);
1150
- if (channel) this.fireDensityExpiry(rule, index, channel);
1197
+ if (channel) this.fireDensityExpiry(rule, index, channel, now);
1151
1198
  }
1152
1199
  };
1153
1200
 
@@ -1239,11 +1286,26 @@ function extractFileCandidates(session) {
1239
1286
  return candidates;
1240
1287
  }
1241
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");
1242
1303
  function parseDataUrl(source) {
1243
- const match = source.match(/^data:([^;]+);base64,(.+)$/);
1304
+ const match = source.match(/^data:([^;,]*)((?:;[^;,]*)*)?,(.+)$/);
1244
1305
  if (!match) return null;
1245
- const mimeType = match[1];
1246
- 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]);
1247
1309
  return { buffer, fileName: `file.${guessExtension(mimeType)}`, mimeType };
1248
1310
  }
1249
1311
  __name(parseDataUrl, "parseDataUrl");
@@ -1317,19 +1379,28 @@ async function uploadOne(ctx, client, candidate, difyUser, limit) {
1317
1379
  }
1318
1380
  }
1319
1381
  __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);
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));
1324
1395
  const files = [];
1325
1396
  for (const candidate of limited) {
1326
1397
  const uploaded = await uploadOne(ctx, client, candidate, difyUser, limit);
1327
1398
  if (uploaded) files.push(uploaded);
1328
- else dropped.push(candidate.source);
1399
+ else markDropped(candidate.source);
1329
1400
  }
1330
1401
  return { files, dropped };
1331
1402
  }
1332
- __name(uploadMessageFiles, "uploadMessageFiles");
1403
+ __name(uploadCandidates, "uploadCandidates");
1333
1404
 
1334
1405
  // src/requester/variables.ts
1335
1406
  function resolveSessionField(key, fireCtx, now) {
@@ -1358,7 +1429,7 @@ function resolveSessionField(key, fireCtx, now) {
1358
1429
  case "timestamp":
1359
1430
  return now;
1360
1431
  case "isDirect":
1361
- return session?.isDirect ?? false;
1432
+ return session?.isDirect ?? fireCtx.isDirect ?? false;
1362
1433
  default:
1363
1434
  return "";
1364
1435
  }
@@ -1440,7 +1511,7 @@ ${line}` : line;
1440
1511
  __name(splitText, "splitText");
1441
1512
  function renderReply(input) {
1442
1513
  const messages = [];
1443
- const segments = splitText(input.text, input.config.maxSegmentLength ?? 0);
1514
+ const segments = splitText(input.text, input.config.maxSegmentLength);
1444
1515
  segments.forEach((segment, index) => {
1445
1516
  const elements = [];
1446
1517
  if (index === 0 && input.quoteMessageId) {
@@ -1496,6 +1567,7 @@ function extractTextOutputs(outputs) {
1496
1567
  __name(extractTextOutputs, "extractTextOutputs");
1497
1568
  function parseWorkflowResponse(response) {
1498
1569
  const data = response.data;
1570
+ if (!data) throw new DifyApiError("workflow 响应缺少 data", 200);
1499
1571
  if (data.error) throw new DifyApiError(data.error, 200);
1500
1572
  if (data.status && !SUCCESS_STATUSES.includes(data.status)) {
1501
1573
  throw new DifyApiError(`workflow ${data.status}`, 200);
@@ -1538,11 +1610,13 @@ var RequestRunner = class {
1538
1610
  uploadedFiles: []
1539
1611
  };
1540
1612
  const upload = resolveUploadConfig(this.config);
1541
- if (upload.enabled && fireCtx.session) {
1542
- 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(
1543
1617
  this.ctx,
1544
1618
  client,
1545
- fireCtx.session,
1619
+ [...sessionCandidates, ...urlCandidates],
1546
1620
  requestCtx.difyUser,
1547
1621
  { maxCount: upload.maxCount, maxSizeMb: upload.maxMb }
1548
1622
  );
@@ -1566,17 +1640,20 @@ var RequestRunner = class {
1566
1640
  this.ctx.logger.warn("规则 %s 的请求未返回任何内容", rule.name);
1567
1641
  return;
1568
1642
  }
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
1643
  const messages = renderReply({
1573
1644
  text: reply.text,
1574
1645
  files: reply.files,
1575
- quoteMessageId: shouldQuote ? fireCtx.session?.messageId : void 0,
1646
+ quoteMessageId: this.shouldQuoteReply(fireCtx, startedAt) ? fireCtx.session?.messageId : void 0,
1576
1647
  config: this.config
1577
1648
  });
1578
1649
  await this.deliver(fireCtx, messages);
1579
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
+ }
1580
1657
  /** 按应用类型分流:workflow/completion 无会话,chat 系续接 conversation_id */
1581
1658
  async dispatch(app, client, fireCtx, requestCtx) {
1582
1659
  const base = {
@@ -1614,7 +1691,8 @@ var RequestRunner = class {
1614
1691
  }
1615
1692
  return;
1616
1693
  }
1617
- 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);
1618
1696
  if (!bot) {
1619
1697
  this.ctx.logger.warn("平台 %s 上没有可用 bot,无法发送主动消息", fireCtx.platform);
1620
1698
  return;
@@ -1636,7 +1714,7 @@ var ConversationStore = class {
1636
1714
  }
1637
1715
  /** 粒度归一:bindingKey 与 difyUser 共用,保证两者始终对齐 */
1638
1716
  resolveScopeDimension(rule, fireCtx) {
1639
- if (fireCtx.session?.isDirect || rule.conversationScope === "per-user") return "user";
1717
+ if (this.isDirectOf(fireCtx) || rule.conversationScope === "per-user") return "user";
1640
1718
  switch (rule.conversationScope) {
1641
1719
  case "global":
1642
1720
  return "global";
@@ -1646,13 +1724,17 @@ var ConversationStore = class {
1646
1724
  return "guild";
1647
1725
  }
1648
1726
  }
1727
+ /** 私聊判定:有 session 读 session,无 session 的主动触发读池透传的标记 */
1728
+ isDirectOf(fireCtx) {
1729
+ return fireCtx.session?.isDirect ?? fireCtx.isDirect ?? false;
1730
+ }
1649
1731
  /** bindingKey = 应用 ID + 粒度 + 平台身份 */
1650
1732
  computeBindingKey(appId, rule, fireCtx) {
1651
1733
  const base = `${appId}:${fireCtx.platform}:${fireCtx.selfId}`;
1652
1734
  switch (this.resolveScopeDimension(rule, fireCtx)) {
1653
1735
  // 群内按「群+用户」区分;私聊用 private 段占位
1654
1736
  case "user":
1655
- 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}`;
1656
1738
  case "guild":
1657
1739
  return `${base}:guild:${fireCtx.guildId ?? fireCtx.channelId}`;
1658
1740
  case "channel":
@@ -1669,7 +1751,7 @@ var ConversationStore = class {
1669
1751
  computeDifyUser(rule, fireCtx) {
1670
1752
  switch (this.resolveScopeDimension(rule, fireCtx)) {
1671
1753
  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}`;
1754
+ return this.isDirectOf(fireCtx) ? `koishi:${fireCtx.platform}:private:${fireCtx.userId ?? fireCtx.channelId}` : `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}:${fireCtx.userId ?? fireCtx.channelId}`;
1673
1755
  case "guild":
1674
1756
  return `koishi:${fireCtx.platform}:${fireCtx.guildId ?? fireCtx.channelId}`;
1675
1757
  case "channel":
@@ -1709,16 +1791,25 @@ var ConversationStore = class {
1709
1791
  /**
1710
1792
  * 重置匹配的绑定:本地删行 + 尽力删除 Dify 侧会话(失败仅记录)。
1711
1793
  * 表已无 guildKey 列,按频道匹配时全表读回后解析 bindingKey 过滤(行数量级很小)。
1794
+ * 覆盖 guild / user(群内)/ channel 段与私聊执行时的 private 段;
1795
+ * global 段跨频道,不随单频道重置。
1712
1796
  */
1713
1797
  async resetWhere(filter) {
1714
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 ?? "");
1715
1805
  const matched = rows.filter((row) => {
1716
1806
  if (filter.bindingKey) return row.bindingKey === filter.bindingKey;
1717
1807
  const { platform, scope, ids } = this.parseBindingKey(row.bindingKey);
1718
- const [guildPlatform, guildId] = (filter.guildKey ?? "").split(":");
1719
1808
  if (platform !== guildPlatform) return false;
1720
1809
  if (scope === "guild") return ids[0] === guildId;
1721
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;
1722
1813
  return false;
1723
1814
  });
1724
1815
  let reset = 0;
@@ -1761,8 +1852,12 @@ __name(defineTables, "defineTables");
1761
1852
  // src/commands/misc.ts
1762
1853
  function registerMiscCommands(parent, conversationStore) {
1763
1854
  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 });
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
+ });
1766
1861
  if (!reset) return session.text("dify-ai.commands.conv-empty");
1767
1862
  return session.text("dify-ai.commands.conv-reset", [reset]);
1768
1863
  });
@@ -1772,6 +1867,7 @@ __name(registerMiscCommands, "registerMiscCommands");
1772
1867
  // src/index.ts
1773
1868
  var name = "dify-ai";
1774
1869
  var inject = { required: ["database"] };
1870
+ var PROBE_TIMEOUT_MS = 1e4;
1775
1871
  function apply(ctx, config) {
1776
1872
  ctx.i18n.define("zh-CN", zh_CN_default);
1777
1873
  ctx.i18n.define("en-US", en_US_default);
@@ -1782,18 +1878,20 @@ function apply(ctx, config) {
1782
1878
  const conversationStore = new ConversationStore(ctx, apps);
1783
1879
  const runner = new RequestRunner(ctx, config, conversationStore, history, apps, appTypes);
1784
1880
  const engine = new TriggerEngine(ctx, runner, history, rules, cooldown);
1785
- ctx.on("ready", async () => {
1786
- await probeAppTypes(ctx, config, apps, appTypes);
1881
+ ctx.on("ready", () => {
1787
1882
  engine.start();
1883
+ void probeAppTypes(ctx, apps, appTypes);
1788
1884
  });
1789
1885
  const admin = ctx.command("dify", { authority: 3 });
1790
1886
  registerMiscCommands(admin, conversationStore);
1791
1887
  }
1792
1888
  __name(apply, "apply");
1793
- 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;
1794
1892
  await Promise.all(
1795
- [...apps.values()].filter((app) => app.appType === "auto").map(async (app) => {
1796
- const client = createDifyClient(ctx, app, config.requestTimeout);
1893
+ autoApps.map(async (app) => {
1894
+ const client = createDifyClient(ctx, app, PROBE_TIMEOUT_MS);
1797
1895
  if (!client) return;
1798
1896
  try {
1799
1897
  const info = await client.getAppInfo();
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.7",
5
5
  "author": "PinkElysia",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/PinkElysiaDev/dify-ai-plugin-for-Koishi#readme",