koishi-plugin-echo-cave 1.30.0 → 1.31.1

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/README.md CHANGED
@@ -85,6 +85,7 @@ npm install koishi-plugin-echo-cave
85
85
  | `autoBindSingleForwardUser` | boolean | `false` | 转发消息仅识别到一个用户时,是否默认自动绑定该用户 |
86
86
  | `forwardSpecialUserHandlingMode` | `'ignore' \| 'reject' \| 'confirm'` | `'ignore'` | 检测到转发消息中包含用户 `1094950020` 时的处理方式:忽略、提醒后拒绝存储、或提醒并要求确认后再存储 |
87
87
  | `alpha` | number | `0.2` | 加权随机抽取的调整因子,控制抽取次数对概率的影响程度,值越大影响越明显 |
88
+ | `s3UploadFailureFallbackMode` | `'local' \| 'original-link'` | `'local'` | 仅在 `mediaStorage='s3'` 时生效:S3 上传失败后回退到本地存储,或保留原始链接 |
88
89
  | `enableAutoReindex` | boolean | `false` | 是否启用每日自动重整回声洞 public ID |
89
90
  | `autoReindexTime` | string | `00:00` | 自动重整的每日执行时间,格式为 `HH:mm` |
90
91
 
@@ -108,6 +109,7 @@ npm install koishi-plugin-echo-cave
108
109
  - 如果重排前备份写入失败,命令会直接终止,不会开始改写数据库
109
110
  - 若开启 `enableAutoReindex`,插件会在 `autoReindexTime` 指定的每日时间自动执行同样的 public ID 重排,并输出日志
110
111
  - 如果需要恢复,可执行 `cave.admin.restore-reindex <备份路径>` 读取对应的 JSON 备份并回写数据库
112
+ - 若 `mediaStorage='s3'` 且上传失败,可通过 `s3UploadFailureFallbackMode` 指定回退到本地存储,或直接保留原始媒体链接
111
113
 
112
114
  ## 🤝 贡献指南
113
115
 
@@ -2,6 +2,7 @@ import { Schema } from 'koishi';
2
2
  export type SendFailureHandlingMode = 'auto-delete' | 'daily-report' | 'ignore';
3
3
  export type OversizedMediaCleanupMode = 'delete-cave' | 'keep-cave';
4
4
  export type ForwardSpecialUserHandlingMode = 'ignore' | 'reject' | 'confirm';
5
+ export type S3UploadFailureFallbackMode = 'local' | 'original-link';
5
6
  export interface Config {
6
7
  adminMessageProtection?: boolean;
7
8
  adminIds?: string[];
@@ -22,6 +23,7 @@ export interface Config {
22
23
  forwardSpecialUserHandlingMode?: ForwardSpecialUserHandlingMode;
23
24
  alpha?: number;
24
25
  mediaStorage?: 'local' | 's3';
26
+ s3UploadFailureFallbackMode?: S3UploadFailureFallbackMode;
25
27
  s3Bucket?: string;
26
28
  s3Region?: string;
27
29
  s3Endpoint?: string;
@@ -1,7 +1,15 @@
1
1
  import { Context } from 'koishi';
2
2
  import { EchoCave } from '../index';
3
3
  export declare const ACTIVE_CAVE_TABLE: "echo_cave_v3";
4
- export interface CaveSnapshotRecord {
4
+ export interface CaveMediaUrlFields {
5
+ fileUrls: string[];
6
+ imageUrls: string[];
7
+ recordUrls: string[];
8
+ videoUrls: string[];
9
+ }
10
+ export declare function createEmptyCaveMediaUrlFields(): CaveMediaUrlFields;
11
+ export declare function normalizeCaveMediaUrlFields(cave?: Partial<CaveMediaUrlFields> | null): CaveMediaUrlFields;
12
+ export interface CaveSnapshotRecord extends CaveMediaUrlFields {
5
13
  id: number;
6
14
  channelId: string;
7
15
  createTime: Date;
@@ -5,6 +5,7 @@ export declare function mergeCavesBetweenChannels(ctx: Context, session: Session
5
5
  export declare function migrateLegacyLocalMedia(ctx: Context, session: Session, cfg: Config): Promise<string>;
6
6
  export declare function migrateMediaToS3(ctx: Context, session: Session, cfg: Config, keepLocalOption?: string): Promise<string>;
7
7
  export declare function inspectMediaRefsForMigration(ctx: Context, session: Session, cfg: Config, idRangesOption?: string): Promise<string>;
8
+ export declare function backfillCaveMediaUrls(ctx: Context, session: Session, cfg: Config, idRangesOption?: string): Promise<string>;
8
9
  export declare function reindexCaveIds(ctx: Context, session: Session, cfg: Config): Promise<string>;
9
10
  export declare function restoreReindexBackup(ctx: Context, session: Session, cfg: Config, backupPathInput?: string): Promise<string>;
10
11
  export declare function registerAutoReindexScheduler(ctx: Context, cfg: Config): void;
package/lib/index.cjs CHANGED
@@ -33460,7 +33460,30 @@ module.exports = __toCommonJS(index_exports);
33460
33460
 
33461
33461
  // src/core/cave-store.ts
33462
33462
  var ACTIVE_CAVE_TABLE = "echo_cave_v3";
33463
+ function createEmptyCaveMediaUrlFields() {
33464
+ return {
33465
+ fileUrls: [],
33466
+ imageUrls: [],
33467
+ recordUrls: [],
33468
+ videoUrls: []
33469
+ };
33470
+ }
33471
+ function normalizeStringList(value) {
33472
+ if (!Array.isArray(value)) {
33473
+ return [];
33474
+ }
33475
+ return value.filter((item) => typeof item === "string");
33476
+ }
33477
+ function normalizeCaveMediaUrlFields(cave) {
33478
+ return {
33479
+ fileUrls: normalizeStringList(cave?.fileUrls),
33480
+ imageUrls: normalizeStringList(cave?.imageUrls),
33481
+ recordUrls: normalizeStringList(cave?.recordUrls),
33482
+ videoUrls: normalizeStringList(cave?.videoUrls)
33483
+ };
33484
+ }
33463
33485
  function toCaveSnapshotRecord(cave) {
33486
+ const mediaUrlFields = normalizeCaveMediaUrlFields(cave);
33464
33487
  return {
33465
33488
  id: cave.id,
33466
33489
  channelId: cave.channelId,
@@ -33471,7 +33494,8 @@ function toCaveSnapshotRecord(cave) {
33471
33494
  content: cave.content,
33472
33495
  relatedUsers: [...cave.relatedUsers],
33473
33496
  drawCount: cave.drawCount,
33474
- picDrawCount: cave.picDrawCount ?? 0
33497
+ picDrawCount: cave.picDrawCount ?? 0,
33498
+ ...mediaUrlFields
33475
33499
  };
33476
33500
  }
33477
33501
  function toCaveBackupRecord(cave) {
@@ -33501,6 +33525,7 @@ async function removeCaveByEntryId(ctx, entryId) {
33501
33525
  await ctx.database.remove(ACTIVE_CAVE_TABLE, entryId);
33502
33526
  }
33503
33527
  async function createCaveRecord(ctx, cave) {
33528
+ const mediaUrlFields = normalizeCaveMediaUrlFields(cave);
33504
33529
  return await ctx.database.create(ACTIVE_CAVE_TABLE, {
33505
33530
  id: cave.id,
33506
33531
  channelId: cave.channelId,
@@ -33511,7 +33536,8 @@ async function createCaveRecord(ctx, cave) {
33511
33536
  content: cave.content,
33512
33537
  relatedUsers: [...cave.relatedUsers],
33513
33538
  drawCount: cave.drawCount,
33514
- picDrawCount: cave.picDrawCount ?? 0
33539
+ picDrawCount: cave.picDrawCount ?? 0,
33540
+ ...mediaUrlFields
33515
33541
  });
33516
33542
  }
33517
33543
  async function updateCaveByEntryId(ctx, entryId, data2) {
@@ -33557,6 +33583,7 @@ async function checkUsersInGroup(ctx, session, userIds) {
33557
33583
  var import_axios = __toESM(require("axios"), 1);
33558
33584
  var import_client_s3 = __toESM(require_dist_cjs71(), 1);
33559
33585
  var import_s3_request_presigner = __toESM(require_dist_cjs73(), 1);
33586
+ var import_node_crypto = require("node:crypto");
33560
33587
  var import_node_fs = require("node:fs");
33561
33588
  var import_node_path = __toESM(require("node:path"), 1);
33562
33589
  var import_node_stream = require("node:stream");
@@ -33611,9 +33638,108 @@ function createEmptyStats() {
33611
33638
  mediaFailed: 0
33612
33639
  };
33613
33640
  }
33641
+ function getMediaUrlFieldName(type) {
33642
+ switch (type) {
33643
+ case "image":
33644
+ return "imageUrls";
33645
+ case "video":
33646
+ return "videoUrls";
33647
+ case "record":
33648
+ return "recordUrls";
33649
+ default:
33650
+ return "fileUrls";
33651
+ }
33652
+ }
33653
+ function hasMediaUrlFieldChanges(current, next) {
33654
+ const normalizedCurrent = normalizeCaveMediaUrlFields(current);
33655
+ return JSON.stringify(normalizedCurrent.fileUrls) !== JSON.stringify(next.fileUrls) || JSON.stringify(normalizedCurrent.imageUrls) !== JSON.stringify(next.imageUrls) || JSON.stringify(normalizedCurrent.recordUrls) !== JSON.stringify(next.recordUrls) || JSON.stringify(normalizedCurrent.videoUrls) !== JSON.stringify(next.videoUrls);
33656
+ }
33657
+ function createUniqueStringList(values) {
33658
+ return [...new Set(values.filter(Boolean))];
33659
+ }
33660
+ function normalizeSourceIdentity(fileRef) {
33661
+ if (fileRef.startsWith("file:///")) {
33662
+ return normalizePathForComparison(fromFileUri(fileRef));
33663
+ }
33664
+ if (import_node_path.default.isAbsolute(fileRef) || /^[A-Za-z]:[\\/]/.test(fileRef)) {
33665
+ return normalizePathForComparison(fileRef);
33666
+ }
33667
+ if (fileRef.startsWith("s3://")) {
33668
+ return fileRef.toLowerCase();
33669
+ }
33670
+ return fileRef;
33671
+ }
33672
+ function getFileNameFromRef(fileRef, type) {
33673
+ if (fileRef.startsWith("file:///")) {
33674
+ return import_node_path.default.basename(fromFileUri(fileRef));
33675
+ }
33676
+ if (import_node_path.default.isAbsolute(fileRef) || /^[A-Za-z]:[\\/]/.test(fileRef)) {
33677
+ return import_node_path.default.basename(fileRef);
33678
+ }
33679
+ if (fileRef.startsWith("s3://")) {
33680
+ const location = parseS3Uri(fileRef);
33681
+ if (location) {
33682
+ return import_node_path.default.basename(location.key);
33683
+ }
33684
+ }
33685
+ if (/^https?:\/\//i.test(fileRef)) {
33686
+ try {
33687
+ return import_node_path.default.basename(new URL(fileRef).pathname) || `media.${getDefaultExtension(type)}`;
33688
+ } catch (error2) {
33689
+ return `media.${getDefaultExtension(type)}`;
33690
+ }
33691
+ }
33692
+ return `media.${getDefaultExtension(type)}`;
33693
+ }
33694
+ function buildStableTransferFileName(fileRef, type) {
33695
+ const sourceName = getFileNameFromRef(fileRef, type);
33696
+ const parsedName = import_node_path.default.parse(sourceName);
33697
+ const baseName = parsedName.name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "media";
33698
+ const extension = parsedName.ext.replace(/^\./, "") || getDefaultExtension(type);
33699
+ const hash = (0, import_node_crypto.createHash)("sha1").update(normalizeSourceIdentity(fileRef)).digest("hex").slice(0, 12);
33700
+ return `${baseName}-${hash}.${extension}`;
33701
+ }
33702
+ async function removeFileIfExists(filePath) {
33703
+ try {
33704
+ await import_node_fs.promises.unlink(filePath);
33705
+ } catch (error2) {
33706
+ const fileError = error2;
33707
+ if (fileError.code !== "ENOENT") {
33708
+ throw error2;
33709
+ }
33710
+ }
33711
+ }
33712
+ async function writeTransferredLocalMedia(ctx, fileRef, channelId, type, buffer) {
33713
+ const targetDir = getLocalMediaV2Dir(ctx, channelId, type);
33714
+ await import_node_fs.promises.mkdir(targetDir, { recursive: true });
33715
+ const targetPath = import_node_path.default.join(targetDir, buildStableTransferFileName(fileRef, type));
33716
+ await removeFileIfExists(targetPath);
33717
+ await import_node_fs.promises.writeFile(targetPath, buffer);
33718
+ return targetPath;
33719
+ }
33720
+ async function uploadTransferredMediaToS3(cfg, fileRef, channelId, type, buffer, contentType) {
33721
+ const client = getS3Client(cfg);
33722
+ const bucket = cfg.s3Bucket;
33723
+ if (!bucket) {
33724
+ throw new Error("S3 bucket is not configured.");
33725
+ }
33726
+ const key = buildS3Key(cfg, channelId, type, buildStableTransferFileName(fileRef, type));
33727
+ await client.send(
33728
+ new import_client_s3.PutObjectCommand({
33729
+ Bucket: bucket,
33730
+ Key: key,
33731
+ Body: buffer,
33732
+ ContentType: contentType
33733
+ })
33734
+ );
33735
+ return { bucket, key };
33736
+ }
33614
33737
  function getStorageMode(cfg) {
33615
33738
  return cfg.mediaStorage === "s3" ? "s3" : "local";
33616
33739
  }
33740
+ function getS3UploadFailureFallbackMode(cfg) {
33741
+ return cfg.s3UploadFailureFallbackMode === "original-link" ? "original-link" : "local";
33742
+ }
33617
33743
  function hasS3Config(cfg) {
33618
33744
  return Boolean(cfg.s3Bucket && cfg.s3Region);
33619
33745
  }
@@ -33987,12 +34113,13 @@ async function createPresignedGetUrl(cfg, location) {
33987
34113
  }
33988
34114
  async function transferLocalFileToChannel(ctx, sourcePath, type, targetChannelId, mode) {
33989
34115
  const targetDir = getLocalMediaV2Dir(ctx, targetChannelId, type);
33990
- if (isPathInsideDir(sourcePath, targetDir)) {
34116
+ await import_node_fs.promises.mkdir(targetDir, { recursive: true });
34117
+ const sourceRef = toFileUri(sourcePath);
34118
+ const targetPath = import_node_path.default.join(targetDir, buildStableTransferFileName(sourceRef, type));
34119
+ if (normalizePathForComparison(sourcePath) === normalizePathForComparison(targetPath)) {
33991
34120
  return toFileUri(sourcePath);
33992
34121
  }
33993
- await import_node_fs.promises.mkdir(targetDir, { recursive: true });
33994
- const extension = import_node_path.default.extname(sourcePath).slice(1) || getDefaultExtension(type);
33995
- const targetPath = import_node_path.default.join(targetDir, `${(0, import_uuid2.v4)().replace(/-/g, "")}.${extension}`);
34122
+ await removeFileIfExists(targetPath);
33996
34123
  await import_node_fs.promises.copyFile(sourcePath, targetPath);
33997
34124
  return toFileUri(targetPath);
33998
34125
  }
@@ -34001,8 +34128,12 @@ async function transferS3ObjectToChannel(cfg, source, type, targetChannelId, mod
34001
34128
  if (!bucket) {
34002
34129
  throw new Error("S3 bucket is not configured.");
34003
34130
  }
34004
- const extension = import_node_path.default.extname(source.key).slice(1) || getDefaultExtension(type);
34005
- const key = buildS3Key(cfg, targetChannelId, type, `${(0, import_uuid2.v4)().replace(/-/g, "")}.${extension}`);
34131
+ const key = buildS3Key(
34132
+ cfg,
34133
+ targetChannelId,
34134
+ type,
34135
+ buildStableTransferFileName(toS3Uri(source), type)
34136
+ );
34006
34137
  const target = { bucket, key };
34007
34138
  if (source.bucket === target.bucket && source.key === target.key) {
34008
34139
  return toS3Uri(source);
@@ -34051,19 +34182,19 @@ async function transferMediaRefToChannel(ctx, fileRef, type, targetChannelId, ta
34051
34182
  if (targetMode === "local") {
34052
34183
  if (isFileUri(fileRef)) {
34053
34184
  const localSourcePath = fromFileUri(fileRef);
34054
- const nextRef2 = await transferLocalFileToChannel(
34185
+ const nextRef = await transferLocalFileToChannel(
34055
34186
  ctx,
34056
34187
  localSourcePath,
34057
34188
  type,
34058
34189
  targetChannelId,
34059
34190
  transferMode
34060
34191
  );
34061
- const plan3 = {
34062
- nextRef: nextRef2,
34063
- rollback: createDeleteLocalFilePlan(fromFileUri(nextRef2)).rollback
34192
+ const plan2 = {
34193
+ nextRef,
34194
+ rollback: createDeleteLocalFilePlan(fromFileUri(nextRef)).rollback
34064
34195
  };
34065
34196
  if (transferMode === "move") {
34066
- plan3.commit = async () => {
34197
+ plan2.commit = async () => {
34067
34198
  try {
34068
34199
  await import_node_fs.promises.unlink(localSourcePath);
34069
34200
  base64Cache.delete(localSourcePath);
@@ -34076,20 +34207,25 @@ async function transferMediaRefToChannel(ctx, fileRef, type, targetChannelId, ta
34076
34207
  } else {
34077
34208
  stats.mediaCopied += 1;
34078
34209
  }
34079
- state2.transferPlans.set(cacheKey, plan3);
34080
- return plan3;
34210
+ state2.transferPlans.set(cacheKey, plan2);
34211
+ return plan2;
34081
34212
  }
34082
- const loaded2 = await loadMediaBuffer(ctx, fileRef, cfg);
34083
- const extension2 = import_node_path.default.extname(loaded2.sourceKey).slice(1) || getDefaultExtension(type);
34084
- const targetPath = await writeLocalMedia(ctx, targetChannelId, type, loaded2.buffer, extension2);
34085
- const plan2 = {
34213
+ const loaded = await loadMediaBuffer(ctx, fileRef, cfg);
34214
+ const targetPath = await writeTransferredLocalMedia(
34215
+ ctx,
34216
+ fileRef,
34217
+ targetChannelId,
34218
+ type,
34219
+ loaded.buffer
34220
+ );
34221
+ const plan = {
34086
34222
  nextRef: toFileUri(targetPath),
34087
34223
  rollback: createDeleteLocalFilePlan(targetPath).rollback
34088
34224
  };
34089
34225
  if (transferMode === "move" && isS3Uri(fileRef)) {
34090
34226
  const source = parseS3Uri(fileRef);
34091
34227
  if (source) {
34092
- plan2.commit = async () => {
34228
+ plan.commit = async () => {
34093
34229
  try {
34094
34230
  await deleteS3Object(cfg, source);
34095
34231
  } catch (error2) {
@@ -34102,88 +34238,113 @@ async function transferMediaRefToChannel(ctx, fileRef, type, targetChannelId, ta
34102
34238
  } else {
34103
34239
  stats.mediaCopied += 1;
34104
34240
  }
34105
- state2.transferPlans.set(cacheKey, plan2);
34106
- return plan2;
34241
+ state2.transferPlans.set(cacheKey, plan);
34242
+ return plan;
34107
34243
  }
34108
- if (isS3Uri(fileRef)) {
34109
- const source = parseS3Uri(fileRef);
34110
- if (!source) {
34111
- throw new Error(`Invalid S3 uri: ${fileRef}`);
34244
+ try {
34245
+ if (isS3Uri(fileRef)) {
34246
+ const source = parseS3Uri(fileRef);
34247
+ if (!source) {
34248
+ throw new Error(`Invalid S3 uri: ${fileRef}`);
34249
+ }
34250
+ const nextRef2 = await transferS3ObjectToChannel(
34251
+ cfg,
34252
+ source,
34253
+ type,
34254
+ targetChannelId,
34255
+ transferMode
34256
+ );
34257
+ const targetLocation2 = parseS3Uri(nextRef2);
34258
+ const plan2 = {
34259
+ nextRef: nextRef2,
34260
+ rollback: targetLocation2 ? async () => {
34261
+ try {
34262
+ await deleteS3Object(cfg, targetLocation2);
34263
+ } catch (error2) {
34264
+ return;
34265
+ }
34266
+ } : void 0
34267
+ };
34268
+ if (transferMode === "move") {
34269
+ plan2.commit = async () => {
34270
+ try {
34271
+ await deleteS3Object(cfg, source);
34272
+ } catch (error2) {
34273
+ return;
34274
+ }
34275
+ };
34276
+ stats.mediaMoved += 1;
34277
+ stats.mediaDeleted += 1;
34278
+ } else {
34279
+ stats.mediaCopied += 1;
34280
+ }
34281
+ state2.transferPlans.set(cacheKey, plan2);
34282
+ return plan2;
34112
34283
  }
34113
- const nextRef2 = await transferS3ObjectToChannel(
34284
+ const loaded = await loadMediaBuffer(ctx, fileRef, cfg);
34285
+ const location = await uploadTransferredMediaToS3(
34114
34286
  cfg,
34115
- source,
34116
- type,
34287
+ fileRef,
34117
34288
  targetChannelId,
34118
- transferMode
34289
+ type,
34290
+ loaded.buffer,
34291
+ loaded.contentType
34119
34292
  );
34120
- const targetLocation2 = parseS3Uri(nextRef2);
34121
- const plan2 = {
34122
- nextRef: nextRef2,
34123
- rollback: targetLocation2 ? async () => {
34293
+ const nextRef = toS3Uri(location);
34294
+ const targetLocation = parseS3Uri(nextRef);
34295
+ const plan = {
34296
+ nextRef,
34297
+ rollback: targetLocation ? async () => {
34124
34298
  try {
34125
- await deleteS3Object(cfg, targetLocation2);
34299
+ await deleteS3Object(cfg, targetLocation);
34126
34300
  } catch (error2) {
34127
34301
  return;
34128
34302
  }
34129
34303
  } : void 0
34130
34304
  };
34131
- if (transferMode === "move") {
34132
- plan2.commit = async () => {
34305
+ if (transferMode === "move" && isFileUri(fileRef)) {
34306
+ const currentPath = fromFileUri(fileRef);
34307
+ plan.commit = async () => {
34133
34308
  try {
34134
- await deleteS3Object(cfg, source);
34309
+ await import_node_fs.promises.unlink(currentPath);
34310
+ base64Cache.delete(currentPath);
34311
+ base64Cache.delete(fileRef);
34135
34312
  } catch (error2) {
34136
34313
  return;
34137
34314
  }
34138
34315
  };
34139
- stats.mediaMoved += 1;
34140
34316
  stats.mediaDeleted += 1;
34141
- } else {
34142
- stats.mediaCopied += 1;
34317
+ stats.mediaMoved += 1;
34143
34318
  }
34144
- state2.transferPlans.set(cacheKey, plan2);
34145
- return plan2;
34146
- }
34147
- const loaded = await loadMediaBuffer(ctx, fileRef, cfg);
34148
- const extension = import_node_path.default.extname(loaded.sourceKey).slice(1) || getDefaultExtension(type);
34149
- const location = await uploadBufferToS3(
34150
- cfg,
34151
- targetChannelId,
34152
- type,
34153
- loaded.buffer,
34154
- extension,
34155
- loaded.contentType
34156
- );
34157
- const nextRef = toS3Uri(location);
34158
- const targetLocation = parseS3Uri(nextRef);
34159
- const plan = {
34160
- nextRef,
34161
- rollback: targetLocation ? async () => {
34162
- try {
34163
- await deleteS3Object(cfg, targetLocation);
34164
- } catch (error2) {
34165
- return;
34166
- }
34167
- } : void 0
34168
- };
34169
- if (transferMode === "move" && isFileUri(fileRef)) {
34170
- const currentPath = fromFileUri(fileRef);
34171
- plan.commit = async () => {
34172
- try {
34173
- await import_node_fs.promises.unlink(currentPath);
34174
- base64Cache.delete(currentPath);
34175
- base64Cache.delete(fileRef);
34176
- } catch (error2) {
34177
- return;
34178
- }
34179
- };
34180
- stats.mediaDeleted += 1;
34181
- stats.mediaMoved += 1;
34319
+ state2.transferPlans.set(cacheKey, plan);
34320
+ stats.mediaUploaded += 1;
34321
+ await hooks?.onS3Upload?.(type, nextRef);
34322
+ return plan;
34323
+ } catch (error2) {
34324
+ const fallbackMode = getS3UploadFailureFallbackMode(cfg);
34325
+ ctx.logger.warn(
34326
+ `Failed to store media in S3 for ${fileRef}, fallback mode=${fallbackMode}: ${error2}`
34327
+ );
34328
+ if (fallbackMode === "original-link") {
34329
+ const plan = { nextRef: fileRef };
34330
+ state2.transferPlans.set(cacheKey, plan);
34331
+ return plan;
34332
+ }
34333
+ const fallbackPlan = await transferMediaRefToChannel(
34334
+ ctx,
34335
+ fileRef,
34336
+ type,
34337
+ targetChannelId,
34338
+ "local",
34339
+ transferMode,
34340
+ cfg,
34341
+ state2,
34342
+ stats,
34343
+ hooks
34344
+ );
34345
+ state2.transferPlans.set(cacheKey, fallbackPlan);
34346
+ return fallbackPlan;
34182
34347
  }
34183
- state2.transferPlans.set(cacheKey, plan);
34184
- stats.mediaUploaded += 1;
34185
- await hooks?.onS3Upload?.(type, nextRef);
34186
- return plan;
34187
34348
  }
34188
34349
  async function mutateMessageContent(ctx, content, handler) {
34189
34350
  const parsed = JSON.parse(content);
@@ -34234,15 +34395,15 @@ async function processStoredMessageMedia(ctx, content, cfg, channelId, progressO
34234
34395
  });
34235
34396
  return rewritten.content;
34236
34397
  }
34237
- async function collectMessageMediaRefs(ctx, content) {
34398
+ async function collectStoredMessageMediaUrls(ctx, content) {
34238
34399
  const parsed = JSON.parse(content);
34239
34400
  const elements = Array.isArray(parsed) ? parsed : [parsed];
34240
- const refs = [];
34401
+ const refs = createEmptyCaveMediaUrlFields();
34241
34402
  const visitElement = async (element) => {
34242
34403
  if (isMediaType(element.type)) {
34243
34404
  const fileRef = getElementFileRef(ctx, element, element.type);
34244
34405
  if (fileRef) {
34245
- refs.push(fileRef);
34406
+ refs[getMediaUrlFieldName(element.type)].push(fileRef);
34246
34407
  }
34247
34408
  }
34248
34409
  const contentValue = element.data?.content;
@@ -34255,7 +34416,16 @@ async function collectMessageMediaRefs(ctx, content) {
34255
34416
  for (const element of elements) {
34256
34417
  await visitElement(element);
34257
34418
  }
34258
- return refs;
34419
+ return {
34420
+ fileUrls: createUniqueStringList(refs.fileUrls),
34421
+ imageUrls: createUniqueStringList(refs.imageUrls),
34422
+ recordUrls: createUniqueStringList(refs.recordUrls),
34423
+ videoUrls: createUniqueStringList(refs.videoUrls)
34424
+ };
34425
+ }
34426
+ async function collectMessageMediaRefs(ctx, content) {
34427
+ const refs = await collectStoredMessageMediaUrls(ctx, content);
34428
+ return [...refs.fileUrls, ...refs.imageUrls, ...refs.recordUrls, ...refs.videoUrls];
34259
34429
  }
34260
34430
  async function collectCavesReferencingFiles(ctx, targetPaths) {
34261
34431
  const targetSet = new Set(targetPaths.map((filePath) => normalizePathForComparison(filePath)));
@@ -34449,15 +34619,28 @@ async function saveMedia(ctx, mediaElement, type, cfg, channelId, progressOption
34449
34619
  return mediaUrl;
34450
34620
  }
34451
34621
  if (getStorageMode(cfg) === "s3") {
34452
- const location = await uploadBufferToS3(
34453
- cfg,
34454
- channelId,
34455
- type,
34456
- buffer,
34457
- extension,
34458
- contentType || getMimeTypeByExtension(`.${extension}`, type)
34459
- );
34460
- return toS3Uri(location);
34622
+ try {
34623
+ const location = await uploadBufferToS3(
34624
+ cfg,
34625
+ channelId,
34626
+ type,
34627
+ buffer,
34628
+ extension,
34629
+ contentType || getMimeTypeByExtension(`.${extension}`, type)
34630
+ );
34631
+ return toS3Uri(location);
34632
+ } catch (error2) {
34633
+ const fallbackMode = getS3UploadFailureFallbackMode(cfg);
34634
+ ctx.logger.warn(
34635
+ `Failed to upload ${type} to S3 for ${mediaUrl}, fallback mode=${fallbackMode}: ${error2}`
34636
+ );
34637
+ if (fallbackMode === "original-link") {
34638
+ return mediaUrl;
34639
+ }
34640
+ const savedPath2 = await writeLocalMedia(ctx, channelId, type, buffer, extension);
34641
+ await debouncedCleanup(ctx, cfg, type, channelId);
34642
+ return savedPath2;
34643
+ }
34461
34644
  }
34462
34645
  const savedPath = await writeLocalMedia(ctx, channelId, type, buffer, extension);
34463
34646
  await debouncedCleanup(ctx, cfg, type, channelId);
@@ -34707,11 +34890,16 @@ async function migrateLocalMediaToV2(ctx, cfg) {
34707
34890
  stats,
34708
34891
  (fileRef, type) => isLegacyLocalMediaRef(ctx, fileRef, type)
34709
34892
  );
34710
- if (rewritten.content !== cave.content) {
34893
+ const mediaUrlFields = await collectStoredMessageMediaUrls(ctx, rewritten.content);
34894
+ const shouldUpdateMediaFields = hasMediaUrlFieldChanges(cave, mediaUrlFields);
34895
+ if (rewritten.content !== cave.content || shouldUpdateMediaFields) {
34711
34896
  if (typeof cave.entryId !== "number") {
34712
34897
  throw new Error(`missing_entry_id_for_cave_${cave.id}`);
34713
34898
  }
34714
- await updateCaveByEntryId(ctx, cave.entryId, { content: rewritten.content });
34899
+ await updateCaveByEntryId(ctx, cave.entryId, {
34900
+ content: rewritten.content,
34901
+ ...mediaUrlFields
34902
+ });
34715
34903
  await runTransferPlans(rewritten.plans, "commit");
34716
34904
  }
34717
34905
  } catch (error2) {
@@ -34751,11 +34939,16 @@ async function migrateLocalMediaToS3(ctx, cfg, keepLocal, createHooks) {
34751
34939
  (fileRef, type) => isFileUri(fileRef) || isLegacyLocalMediaRef(ctx, fileRef, type),
34752
34940
  hooks
34753
34941
  );
34754
- if (rewritten.content !== cave.content) {
34942
+ const mediaUrlFields = await collectStoredMessageMediaUrls(ctx, rewritten.content);
34943
+ const shouldUpdateMediaFields = hasMediaUrlFieldChanges(cave, mediaUrlFields);
34944
+ if (rewritten.content !== cave.content || shouldUpdateMediaFields) {
34755
34945
  if (typeof cave.entryId !== "number") {
34756
34946
  throw new Error(`missing_entry_id_for_cave_${cave.id}`);
34757
34947
  }
34758
- await updateCaveByEntryId(ctx, cave.entryId, { content: rewritten.content });
34948
+ await updateCaveByEntryId(ctx, cave.entryId, {
34949
+ content: rewritten.content,
34950
+ ...mediaUrlFields
34951
+ });
34759
34952
  await runTransferPlans(rewritten.plans, "commit");
34760
34953
  await hooks?.onMigrationCommitted?.();
34761
34954
  }
@@ -34798,6 +34991,7 @@ async function mergeChannelCaves(ctx, cfg, sourceChannelId, targetChannelId, kee
34798
34991
  );
34799
34992
  if (keepSource) {
34800
34993
  const nextId = await getNextCavePublicId(ctx);
34994
+ const mediaUrlFields = await collectStoredMessageMediaUrls(ctx, rewritten.content);
34801
34995
  await createCaveRecord(ctx, {
34802
34996
  id: nextId,
34803
34997
  channelId: targetChannelId,
@@ -34808,15 +35002,18 @@ async function mergeChannelCaves(ctx, cfg, sourceChannelId, targetChannelId, kee
34808
35002
  content: rewritten.content,
34809
35003
  relatedUsers: cave.relatedUsers,
34810
35004
  drawCount: cave.drawCount,
34811
- picDrawCount: cave.picDrawCount ?? 0
35005
+ picDrawCount: cave.picDrawCount ?? 0,
35006
+ ...mediaUrlFields
34812
35007
  });
34813
35008
  } else {
34814
35009
  if (typeof cave.entryId !== "number") {
34815
35010
  throw new Error(`missing_entry_id_for_cave_${cave.id}`);
34816
35011
  }
35012
+ const mediaUrlFields = await collectStoredMessageMediaUrls(ctx, rewritten.content);
34817
35013
  await updateCaveByEntryId(ctx, cave.entryId, {
34818
35014
  channelId: targetChannelId,
34819
- content: rewritten.content
35015
+ content: rewritten.content,
35016
+ ...mediaUrlFields
34820
35017
  });
34821
35018
  }
34822
35019
  await runTransferPlans(rewritten.plans, "commit");
@@ -34920,12 +35117,21 @@ var import_node_fs2 = require("node:fs");
34920
35117
  var import_node_path2 = __toESM(require("node:path"), 1);
34921
35118
  var REINDEX_SPECIAL_OFFSET = 1e6;
34922
35119
  var caveMaintenanceLock = false;
35120
+ function hasStringArray(value) {
35121
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
35122
+ }
35123
+ function areMediaUrlFieldsEmpty(fields) {
35124
+ const normalized = normalizeCaveMediaUrlFields(fields);
35125
+ return normalized.fileUrls.length === 0 && normalized.imageUrls.length === 0 && normalized.recordUrls.length === 0 && normalized.videoUrls.length === 0;
35126
+ }
34923
35127
  function cloneCaveRecord(cave, id) {
35128
+ const mediaUrlFields = normalizeCaveMediaUrlFields(cave);
34924
35129
  return {
34925
35130
  ...cave,
34926
35131
  createTime: new Date(cave.createTime),
34927
35132
  id,
34928
- relatedUsers: [...cave.relatedUsers]
35133
+ relatedUsers: [...cave.relatedUsers],
35134
+ ...mediaUrlFields
34929
35135
  };
34930
35136
  }
34931
35137
  async function removeCavesByEntryIds(ctx, entryIds) {
@@ -34964,6 +35170,7 @@ function buildSequentialCaveSnapshot(caves) {
34964
35170
  return [...caves].sort((a5, b5) => a5.id - b5.id).map((cave, index) => cloneCaveRecord(cave, index + 1));
34965
35171
  }
34966
35172
  function normalizeCaveRecord(cave) {
35173
+ const mediaUrlFields = normalizeCaveMediaUrlFields(cave);
34967
35174
  return {
34968
35175
  channelId: cave.channelId,
34969
35176
  content: cave.content,
@@ -34974,7 +35181,8 @@ function normalizeCaveRecord(cave) {
34974
35181
  originUserId: cave.originUserId,
34975
35182
  relatedUsers: [...cave.relatedUsers],
34976
35183
  type: cave.type,
34977
- userId: cave.userId
35184
+ userId: cave.userId,
35185
+ ...mediaUrlFields
34978
35186
  };
34979
35187
  }
34980
35188
  function normalizeBackupRecord(cave) {
@@ -34990,7 +35198,7 @@ function isEchoCaveRecord(value) {
34990
35198
  if (!isRecordObject(value)) {
34991
35199
  return false;
34992
35200
  }
34993
- return typeof value.id === "number" && typeof value.channelId === "string" && (value.createTime instanceof Date || typeof value.createTime === "string" || typeof value.createTime === "number") && typeof value.userId === "string" && typeof value.originUserId === "string" && (value.type === "forward" || value.type === "msg") && typeof value.content === "string" && Array.isArray(value.relatedUsers) && value.relatedUsers.every((user) => typeof user === "string") && typeof value.drawCount === "number" && (typeof value.picDrawCount === "number" || typeof value.picDrawCount === "undefined");
35201
+ return typeof value.id === "number" && typeof value.channelId === "string" && (value.createTime instanceof Date || typeof value.createTime === "string" || typeof value.createTime === "number") && typeof value.userId === "string" && typeof value.originUserId === "string" && (value.type === "forward" || value.type === "msg") && typeof value.content === "string" && Array.isArray(value.relatedUsers) && value.relatedUsers.every((user) => typeof user === "string") && typeof value.drawCount === "number" && (typeof value.picDrawCount === "number" || typeof value.picDrawCount === "undefined") && (typeof value.fileUrls === "undefined" || hasStringArray(value.fileUrls)) && (typeof value.imageUrls === "undefined" || hasStringArray(value.imageUrls)) && (typeof value.recordUrls === "undefined" || hasStringArray(value.recordUrls)) && (typeof value.videoUrls === "undefined" || hasStringArray(value.videoUrls));
34994
35202
  }
34995
35203
  function isCaveBackupRecord(value) {
34996
35204
  if (!isEchoCaveRecord(value)) {
@@ -35342,6 +35550,69 @@ async function inspectMediaRefsForMigration(ctx, session, cfg, idRangesOption) {
35342
35550
  );
35343
35551
  }
35344
35552
  }
35553
+ async function backfillCaveMediaUrls(ctx, session, cfg, idRangesOption) {
35554
+ const accessError = ensureAdminPrivateAccess(session, cfg);
35555
+ if (accessError) {
35556
+ return accessError;
35557
+ }
35558
+ const idRanges = parseIdRanges(idRangesOption);
35559
+ if (idRanges === null) {
35560
+ return session.text("commands.cave.admin.backfill-media-urls.messages.invalidRange");
35561
+ }
35562
+ const displayRanges = idRangesOption?.trim() || getAllRangesLabel(session);
35563
+ const caves = await getAllCaves(ctx);
35564
+ const candidates = caves.filter(
35565
+ (cave) => isIdInRanges(cave.id, idRanges) && areMediaUrlFieldsEmpty(cave)
35566
+ );
35567
+ if (candidates.length === 0) {
35568
+ return session.text("commands.cave.admin.backfill-media-urls.messages.noCandidates", {
35569
+ idRanges: displayRanges
35570
+ });
35571
+ }
35572
+ const confirmed = await requestSecondConfirmation(
35573
+ ctx,
35574
+ session,
35575
+ session.text("commands.cave.admin.backfill-media-urls.messages.confirmSummary", {
35576
+ idRanges: displayRanges,
35577
+ candidateCount: candidates.length
35578
+ }),
35579
+ session.text("commands.cave.admin.backfill-media-urls.messages.confirmRetry"),
35580
+ session.text("commands.cave.admin.backfill-media-urls.messages.confirmTimeout"),
35581
+ session.text("commands.cave.admin.backfill-media-urls.messages.confirmCancelled")
35582
+ );
35583
+ if (!confirmed) {
35584
+ return;
35585
+ }
35586
+ let updatedCount = 0;
35587
+ let skippedWithoutMedia = 0;
35588
+ const failedRecordIds = [];
35589
+ for (const cave of candidates) {
35590
+ try {
35591
+ if (typeof cave.entryId !== "number") {
35592
+ throw new Error(`missing_entry_id_for_cave_${cave.id}`);
35593
+ }
35594
+ const mediaUrlFields = await collectStoredMessageMediaUrls(ctx, cave.content);
35595
+ if (areMediaUrlFieldsEmpty(mediaUrlFields)) {
35596
+ skippedWithoutMedia += 1;
35597
+ continue;
35598
+ }
35599
+ await updateCaveByEntryId(ctx, cave.entryId, mediaUrlFields);
35600
+ updatedCount += 1;
35601
+ } catch (error2) {
35602
+ failedRecordIds.push(cave.id);
35603
+ ctx.logger.warn(`Failed to backfill media URLs for cave #${cave.id}: ${error2}`);
35604
+ }
35605
+ }
35606
+ return appendFailedRecordSummary(
35607
+ session,
35608
+ session.text("commands.cave.admin.backfill-media-urls.messages.backfillDone", {
35609
+ scannedRecords: candidates.length,
35610
+ updatedCount,
35611
+ skippedWithoutMedia
35612
+ }),
35613
+ failedRecordIds
35614
+ );
35615
+ }
35345
35616
  function buildReindexPlan(caves) {
35346
35617
  const maxId = caves.reduce((currentMax, cave) => Math.max(currentMax, cave.id), 0);
35347
35618
  const offset = maxId + caves.length + REINDEX_SPECIAL_OFFSET;
@@ -35741,6 +36012,7 @@ async function addCave(ctx, session, cfg, userIds) {
35741
36012
  finalParsedUserIds,
35742
36013
  originName
35743
36014
  );
36015
+ const mediaUrlFields = await collectStoredMessageMediaUrls(ctx, content);
35744
36016
  try {
35745
36017
  const nextId = await getNextCavePublicId(ctx);
35746
36018
  const result = await ctx.database.create(ACTIVE_CAVE_TABLE, {
@@ -35752,7 +36024,9 @@ async function addCave(ctx, session, cfg, userIds) {
35752
36024
  type,
35753
36025
  content,
35754
36026
  relatedUsers: finalParsedUserIds,
35755
- picDrawCount: 0
36027
+ drawCount: 0,
36028
+ picDrawCount: 0,
36029
+ ...mediaUrlFields
35756
36030
  });
35757
36031
  return session.text(".msgSaved", { id: result.id, relatedUsers: relatedUsersFormatted });
35758
36032
  } catch (error2) {
@@ -37036,6 +37310,18 @@ var zh_CN_default = {
37036
37310
  confirmTimeout: "\u231B \u4E8C\u6B21\u786E\u8BA4\u8D85\u65F6\uFF0C\u68C0\u6D4B\u672A\u6267\u884C\u3002"
37037
37311
  }
37038
37312
  },
37313
+ "cave.admin.backfill-media-urls": {
37314
+ description: "\u4E3A\u5386\u53F2\u56DE\u58F0\u6D1E\u56DE\u586B\u5A92\u4F53 URL \u5217",
37315
+ messages: {
37316
+ invalidRange: "\u274C \u8303\u56F4\u53C2\u6570\u683C\u5F0F\u65E0\u6548\u3002\u8BF7\u4F7F\u7528 0-100,105-240 \u6216 12,18-20 \u8FD9\u6837\u7684\u683C\u5F0F\u3002",
37317
+ noCandidates: "\u{1F50D} \u5728\u8303\u56F4 {idRanges} \u5185\u672A\u627E\u5230\u56DB\u5217\u5A92\u4F53 URL \u90FD\u4E3A\u7A7A\u7684\u8BB0\u5F55\u3002",
37318
+ confirmSummary: "\u26A0\uFE0F \u5373\u5C06\u4E3A\u5386\u53F2\u56DE\u58F0\u6D1E\u56DE\u586B\u5A92\u4F53 URL \u5217\n\u68C0\u6D4B\u8303\u56F4\uFF1A{idRanges}\n\u5019\u9009\u8BB0\u5F55\u6570\uFF1A{candidateCount}\n\n\u672C\u6B21\u64CD\u4F5C\u53EA\u4F1A\u5904\u7406 fileUrls\u3001imageUrls\u3001recordUrls\u3001videoUrls \u56DB\u5217\u90FD\u4E3A\u7A7A\u7684\u8BB0\u5F55\uFF0C\u5E76\u4ECE\u5B58\u50A8\u6D88\u606F\u5185\u5BB9\u4E2D\u63D0\u53D6\u5A92\u4F53\u5F15\u7528\u540E\u5199\u56DE\u6570\u636E\u5E93\u3002\u8BF7\u5728 30 \u79D2\u5185\u8F93\u5165\u201C\u786E\u8BA4\u201D\u7EE7\u7EED\uFF0C\u8F93\u5165\u201C\u53D6\u6D88\u201D\u53EF\u7EC8\u6B62\u3002",
37319
+ confirmRetry: "\u26A0\uFE0F \u672A\u6536\u5230\u201C\u786E\u8BA4\u201D\u3002\u8BF7\u5728\u5269\u4F59\u65F6\u95F4\u5185\u8F93\u5165\u201C\u786E\u8BA4\u201D\u7EE7\u7EED\uFF0C\u6216\u8F93\u5165\u201C\u53D6\u6D88\u201D\u7EC8\u6B62\u3002",
37320
+ confirmCancelled: "\u{1F6D1} \u5DF2\u53D6\u6D88\u672C\u6B21\u5A92\u4F53 URL \u56DE\u586B\u3002",
37321
+ confirmTimeout: "\u231B \u4E8C\u6B21\u786E\u8BA4\u8D85\u65F6\uFF0C\u5A92\u4F53 URL \u56DE\u586B\u672A\u6267\u884C\u3002",
37322
+ backfillDone: "\u2705 \u5A92\u4F53 URL \u56DE\u586B\u5B8C\u6210\uFF1A\u626B\u63CF {scannedRecords} \u6761\u5019\u9009\u8BB0\u5F55\uFF0C\u6210\u529F\u66F4\u65B0 {updatedCount} \u6761\uFF0C\u65E0\u5A92\u4F53\u53EF\u56DE\u586B {skippedWithoutMedia} \u6761\u3002"
37323
+ }
37324
+ },
37039
37325
  "cave.admin.reindex": {
37040
37326
  description: "\u91CD\u6392\u6240\u6709\u56DE\u58F0\u6D1E public ID\uFF0C\u4F7F\u73B0\u6709\u8BB0\u5F55\u91CD\u65B0\u8FDE\u7EED\u7F16\u53F7\u800C\u4E0D\u6539\u52A8\u5185\u90E8\u4E3B\u952E",
37041
37327
  messages: {
@@ -37088,6 +37374,7 @@ var zh_CN_default2 = {
37088
37374
  forwardSpecialUserHandlingMode: "\u68C0\u6D4B\u5230\u7279\u6B8A\u7528\u6237 1094950020 \u65F6\u7684\u5904\u7406\u65B9\u5F0F\uFF1Aignore \u4E3A\u5FFD\u7565\uFF0Creject \u4E3A\u63D0\u9192\u5E76\u62D2\u7EDD\u5B58\u50A8\uFF0Cconfirm \u4E3A\u63D0\u9192\u3001\u5C55\u793A\u8F6C\u53D1\u5185\u5BB9\u5E76\u8981\u6C42\u786E\u8BA4\u540E\u518D\u5B58\u50A8",
37089
37375
  alpha: "\u52A0\u6743\u968F\u673A\u62BD\u53D6\u7684\u8C03\u6574\u56E0\u5B50\uFF0C\u63A7\u5236\u62BD\u53D6\u6B21\u6570\u5BF9\u6982\u7387\u7684\u5F71\u54CD\u7A0B\u5EA6\uFF0C\u503C\u8D8A\u5927\u5F71\u54CD\u8D8A\u660E\u663E",
37090
37376
  mediaStorage: "\u5A92\u4F53\u5B58\u50A8\u65B9\u5F0F\uFF1Alocal \u4E3A\u672C\u5730\uFF0Cs3 \u4E3A\u5BF9\u8C61\u5B58\u50A8",
37377
+ s3UploadFailureFallbackMode: "\u4EC5\u5728\u5A92\u4F53\u5B58\u50A8\u65B9\u5F0F\u4E3A s3 \u65F6\u751F\u6548\uFF1AS3 \u4E0A\u4F20\u5931\u8D25\u540E\u56DE\u9000\u5230\u672C\u5730\u5B58\u50A8\uFF0C\u6216\u4FDD\u7559\u539F\u59CB\u94FE\u63A5",
37091
37378
  s3Bucket: "S3 \u5B58\u50A8\u6876\u540D\u79F0",
37092
37379
  s3Region: "S3 \u533A\u57DF",
37093
37380
  s3Endpoint: "S3 Endpoint\uFF0C\u53EF\u7528\u4E8E\u517C\u5BB9 S3 \u7684\u79C1\u6709\u670D\u52A1",
@@ -37133,6 +37420,7 @@ var Config = import_koishi2.Schema.intersect([
37133
37420
  }).description("\u5A92\u4F53\u5904\u7406"),
37134
37421
  import_koishi2.Schema.object({
37135
37422
  mediaStorage: import_koishi2.Schema.union(["local", "s3"]).default("local"),
37423
+ s3UploadFailureFallbackMode: import_koishi2.Schema.union(["local", "original-link"]).default("local"),
37136
37424
  s3Bucket: import_koishi2.Schema.string().default(""),
37137
37425
  s3Region: import_koishi2.Schema.string().default(""),
37138
37426
  s3Endpoint: import_koishi2.Schema.string().default(""),
@@ -37161,6 +37449,7 @@ var Config = import_koishi2.Schema.intersect([
37161
37449
  var name = "echo-cave";
37162
37450
  var inject = ["database"];
37163
37451
  function toV3Record(record) {
37452
+ const mediaUrlFields = createEmptyCaveMediaUrlFields();
37164
37453
  return {
37165
37454
  id: record.id,
37166
37455
  channelId: record.channelId,
@@ -37171,30 +37460,12 @@ function toV3Record(record) {
37171
37460
  content: record.content,
37172
37461
  relatedUsers: [...record.relatedUsers],
37173
37462
  drawCount: record.drawCount,
37174
- picDrawCount: record.picDrawCount ?? 0
37463
+ picDrawCount: record.picDrawCount ?? 0,
37464
+ ...mediaUrlFields
37175
37465
  };
37176
37466
  }
37177
37467
  function apply(ctx, cfg) {
37178
37468
  ctx.i18n.define("zh-CN", zh_CN_default);
37179
- ctx.model.extend(
37180
- "echo_cave",
37181
- {
37182
- id: "unsigned",
37183
- channelId: "string",
37184
- createTime: "timestamp",
37185
- userId: "string",
37186
- originUserId: "string",
37187
- type: "string",
37188
- content: "text",
37189
- relatedUsers: "list",
37190
- drawCount: { type: "unsigned", initial: 0 },
37191
- picDrawCount: { type: "unsigned", initial: 0 }
37192
- },
37193
- {
37194
- primary: "id",
37195
- autoInc: true
37196
- }
37197
- );
37198
37469
  ctx.model.extend(
37199
37470
  ACTIVE_CAVE_TABLE,
37200
37471
  {
@@ -37208,7 +37479,11 @@ function apply(ctx, cfg) {
37208
37479
  content: "text",
37209
37480
  relatedUsers: "list",
37210
37481
  drawCount: { type: "unsigned", initial: 0 },
37211
- picDrawCount: { type: "unsigned", initial: 0 }
37482
+ picDrawCount: { type: "unsigned", initial: 0 },
37483
+ fileUrls: { type: "list", initial: [] },
37484
+ imageUrls: { type: "list", initial: [] },
37485
+ recordUrls: { type: "list", initial: [] },
37486
+ videoUrls: { type: "list", initial: [] }
37212
37487
  },
37213
37488
  {
37214
37489
  primary: "entryId",
@@ -37262,16 +37537,6 @@ function apply(ctx, cfg) {
37262
37537
  primary: "userId"
37263
37538
  }
37264
37539
  );
37265
- ctx.model.migrate(
37266
- "echo_cave",
37267
- {
37268
- id: "unsigned"
37269
- },
37270
- async (database) => {
37271
- const data2 = await database.get("echo_cave", {});
37272
- await database.upsert("echo_cave_v2", data2);
37273
- }
37274
- );
37275
37540
  ctx.model.migrate("echo_cave_v2", {}, async (database) => {
37276
37541
  const existing = await database.get(ACTIVE_CAVE_TABLE, {});
37277
37542
  if (existing.length > 0) {
@@ -37311,6 +37576,9 @@ function apply(ctx, cfg) {
37311
37576
  ctx.command("cave.admin.inspect-media [idRanges:text]").action(
37312
37577
  async ({ session }, idRanges) => await inspectMediaRefsForMigration(ctx, session, cfg, idRanges)
37313
37578
  );
37579
+ ctx.command("cave.admin.backfill-media-urls [idRanges:text]").action(
37580
+ async ({ session }, idRanges) => await backfillCaveMediaUrls(ctx, session, cfg, idRanges)
37581
+ );
37314
37582
  ctx.command("cave.admin.reindex").action(async ({ session }) => await reindexCaveIds(ctx, session, cfg));
37315
37583
  ctx.command("cave.admin.restore-reindex <backupPath:text>").action(
37316
37584
  async ({ session }, backupPath) => await restoreReindexBackup(ctx, session, cfg, backupPath)
package/lib/index.d.ts CHANGED
@@ -15,6 +15,10 @@ export interface EchoCave {
15
15
  relatedUsers: string[];
16
16
  drawCount: number;
17
17
  picDrawCount: number;
18
+ fileUrls: string[];
19
+ imageUrls: string[];
20
+ recordUrls: string[];
21
+ videoUrls: string[];
18
22
  }
19
23
  export interface EchoCaveSendFailure {
20
24
  id: number;
@@ -1,4 +1,5 @@
1
1
  import { Config } from '../../config/config';
2
+ import { CaveMediaUrlFields } from '../../core/cave-store';
2
3
  import { Context, Session } from 'koishi';
3
4
  export type MediaType = 'image' | 'video' | 'file' | 'record';
4
5
  type MaybeMediaElement = {
@@ -22,6 +23,7 @@ interface CaveMediaRefs {
22
23
  refs: string[];
23
24
  }
24
25
  export declare function processStoredMessageMedia(ctx: Context, content: string, cfg: Config, channelId: string, progressOptions?: MediaSaveProgressOptions): Promise<string>;
26
+ export declare function collectStoredMessageMediaUrls(ctx: Context, content: string): Promise<CaveMediaUrlFields>;
25
27
  export declare function inspectCaveMediaRefs(ctx: Context, shouldInclude?: (caveId: number) => boolean): Promise<CaveMediaRefs[]>;
26
28
  export declare function saveMedia(ctx: Context, mediaElement: Record<string, unknown>, type: MediaType, cfg: Config, channelId: string, progressOptions?: MediaSaveProgressOptions): Promise<string>;
27
29
  export declare function processMediaElement(ctx: Context, element: MaybeMediaElement, cfg: Config, channelId: string, progressOptions?: MediaSaveProgressOptions): Promise<MaybeMediaElement>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-echo-cave",
3
3
  "description": "Group echo cave",
4
- "version": "1.30.0",
4
+ "version": "1.31.1",
5
5
  "main": "lib/index.cjs",
6
6
  "typings": "lib/index.d.ts",
7
7
  "type": "module",