luckytri 1.2.2 → 1.2.4
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.en.md +1 -1
- package/README.md +1 -1
- package/docs/en/CHANGELOG.md +11 -0
- package/docs/en/architecture.md +10 -0
- package/docs/zh/CHANGELOG.md +11 -0
- package/docs/zh/architecture.md +10 -0
- package/package.json +2 -2
- package/public/app/assets/{ChatsPage-GGOaUl3H.js → ChatsPage-C_gKz4mJ.js} +1 -1
- package/public/app/assets/{Empty-BSmsWoGM.js → Empty-y1vXDIjQ.js} +1 -1
- package/public/app/assets/{Field-tUGp0-YL.js → Field-CJG5lERx.js} +1 -1
- package/public/app/assets/{HeartPage-B5_e1hI9.js → HeartPage-5y3avjSy.js} +1 -1
- package/public/app/assets/{LifePage-D5P5kTzy.js → LifePage-BtwRMDAM.js} +1 -1
- package/public/app/assets/{MemoryPage-BrSPiqOS.js → MemoryPage-54noDhWz.js} +1 -1
- package/public/app/assets/{NaturePage-C0BdvfFu.js → NaturePage-DHK5X2Qw.js} +1 -1
- package/public/app/assets/{PeoplePage-DBT70lu7.js → PeoplePage-ZsA7gyxP.js} +1 -1
- package/public/app/assets/{PluginsPage-QBiQg3Jc.js → PluginsPage-BCRRIAhq.js} +1 -1
- package/public/app/assets/SystemPage-DNTD2_nj.css +1 -0
- package/public/app/assets/SystemPage-DWr1TG49.js +1 -0
- package/public/app/assets/{TimePage-BuNKjCgN.js → TimePage-6MUmtSOT.js} +1 -1
- package/public/app/assets/{index-BaXEYeFH.js → index-BokqwNXF.js} +3 -3
- package/public/app/assets/{index-BHZcHUMh.css → index-cDDC-1dI.css} +1 -1
- package/public/app/assets/{knowledge-COyBves6.js → knowledge-BjD9PkXe.js} +1 -1
- package/public/app/assets/{media-8uRD5Ocs.js → media-Ciozo_yb.js} +1 -1
- package/public/app/assets/{mind-CQkvddhx.js → mind-KkinyNC6.js} +1 -1
- package/public/app/index.html +4 -4
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/server/app.js +9 -1
- package/server/channels/onebot/adapter.js +25 -1
- package/server/channels/onebot/index.js +5 -2
- package/server/core/dialogue-contract.js +1 -1
- package/server/core/image-preprocessor.js +41 -1
- package/server/core/message-scheduler.js +12 -2
- package/server/core/orchestrator.js +14 -0
- package/server/core/reply-delivery.js +72 -0
- package/server/core/turn-processor.js +25 -0
- package/server/core/turn.js +25 -1
- package/server/i18n/errors.js +15 -0
- package/server/plugins/zip.js +32 -4
- package/server/stickers/api.js +170 -0
- package/server/stickers/index.js +363 -0
- package/server/stickers/library.js +199 -0
- package/server/stickers/offer.js +123 -0
- package/server/stickers/schema.js +25 -0
- package/server/stickers/zip-import.js +76 -0
- package/public/app/assets/SystemPage-Cf8lKh5C.css +0 -1
- package/public/app/assets/SystemPage-EFOEvom-.js +0 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import sharp from "sharp";
|
|
2
2
|
|
|
3
3
|
const MIB = 1024 * 1024;
|
|
4
|
+
// Largest picture accepted for understanding; bigger ones are shrunk before
|
|
5
|
+
// any model sees them. VISION_SOURCE_CEILING_MB bounds the OneBot frame.
|
|
6
|
+
export const VISION_SOURCE_DEFAULT_MB = 20;
|
|
7
|
+
export const VISION_SOURCE_CEILING_MB = 64;
|
|
4
8
|
const TYPES = {
|
|
5
9
|
jpeg: "image/jpeg",
|
|
6
10
|
png: "image/png",
|
|
@@ -19,7 +23,8 @@ function configured(value, fallback, min, max) {
|
|
|
19
23
|
export function visionImageLimits(env = process.env) {
|
|
20
24
|
return {
|
|
21
25
|
maxSourceBytes: Math.floor(
|
|
22
|
-
configured(env.VISION_SOURCE_MAX_MB,
|
|
26
|
+
configured(env.VISION_SOURCE_MAX_MB, VISION_SOURCE_DEFAULT_MB, 1, 64) *
|
|
27
|
+
MIB,
|
|
23
28
|
),
|
|
24
29
|
maxModelBytes: Math.floor(
|
|
25
30
|
configured(env.VISION_MODEL_MAX_MB, 4, 1, 4) * MIB,
|
|
@@ -33,6 +38,41 @@ export function visionImageLimits(env = process.env) {
|
|
|
33
38
|
};
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// The owner's image-understanding size: the long side and the encoded size
|
|
42
|
+
// a picture is brought down to before a model sees it. A big photo is shrunk
|
|
43
|
+
// and re-encoded rather than refused; many providers reject large images or
|
|
44
|
+
// time out on them.
|
|
45
|
+
export function modelImageLimits({
|
|
46
|
+
visionMaxSide = 2048,
|
|
47
|
+
visionMaxMB = 3,
|
|
48
|
+
visionSourceMaxMB = VISION_SOURCE_DEFAULT_MB,
|
|
49
|
+
} = {}) {
|
|
50
|
+
const source = Math.min(
|
|
51
|
+
VISION_SOURCE_CEILING_MB,
|
|
52
|
+
Math.max(1, Number(visionSourceMaxMB) || VISION_SOURCE_DEFAULT_MB),
|
|
53
|
+
);
|
|
54
|
+
const side = Math.min(
|
|
55
|
+
8192,
|
|
56
|
+
Math.max(512, Math.floor(Number(visionMaxSide) || 2048)),
|
|
57
|
+
);
|
|
58
|
+
const mb = Math.min(4, Math.max(0.5, Number(visionMaxMB) || 3));
|
|
59
|
+
return {
|
|
60
|
+
maxDimension: side,
|
|
61
|
+
maxOutputPixels: side * side,
|
|
62
|
+
maxModelBytes: Math.floor(mb * MIB),
|
|
63
|
+
maxSourceBytes: Math.floor(source * MIB),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The owner's setting, unless the operator pinned the source size in the
|
|
68
|
+
// environment (VISION_SOURCE_MAX_MB), which then wins everywhere.
|
|
69
|
+
export function ownerImageLimits(settings, env = process.env) {
|
|
70
|
+
const limits = modelImageLimits(settings);
|
|
71
|
+
if (env.VISION_SOURCE_MAX_MB != null && env.VISION_SOURCE_MAX_MB !== "")
|
|
72
|
+
limits.maxSourceBytes = visionImageLimits(env).maxSourceBytes;
|
|
73
|
+
return limits;
|
|
74
|
+
}
|
|
75
|
+
|
|
36
76
|
export function imageMime(bytes) {
|
|
37
77
|
if (
|
|
38
78
|
bytes.length >= 3 &&
|
|
@@ -16,6 +16,8 @@ export async function deliver(
|
|
|
16
16
|
// Platform id of an earlier message the first bubble answers, when the
|
|
17
17
|
// conversation has moved past it; the channel may show it as a quote.
|
|
18
18
|
quote = "",
|
|
19
|
+
// People she chose to @, on her first message only, the way one types.
|
|
20
|
+
at = [],
|
|
19
21
|
} = {},
|
|
20
22
|
) {
|
|
21
23
|
const ids = bubbles.map((text, i) => {
|
|
@@ -45,10 +47,16 @@ export async function deliver(
|
|
|
45
47
|
.prepare("UPDATE core_outbox SET status='sending',text=? WHERE id=?")
|
|
46
48
|
.run(outgoing, ids[i]);
|
|
47
49
|
try {
|
|
50
|
+
const first = i === 0 && (quote || at.length);
|
|
48
51
|
const result = await send(
|
|
49
52
|
message,
|
|
50
53
|
outgoing,
|
|
51
|
-
|
|
54
|
+
first
|
|
55
|
+
? {
|
|
56
|
+
...(quote ? { replyTo: quote } : {}),
|
|
57
|
+
...(at.length ? { at: at.map((p) => p.userId) } : {}),
|
|
58
|
+
}
|
|
59
|
+
: undefined,
|
|
52
60
|
);
|
|
53
61
|
repo.db
|
|
54
62
|
.prepare(
|
|
@@ -62,7 +70,9 @@ export async function deliver(
|
|
|
62
70
|
traceId: trace.id,
|
|
63
71
|
replyTargetIds: trace.decision?.targetMessageIds || [],
|
|
64
72
|
},
|
|
65
|
-
|
|
73
|
+
i === 0 && at.length
|
|
74
|
+
? `${at.map((p) => `@${p.name}`).join(" ")} ${outgoing}`
|
|
75
|
+
: outgoing,
|
|
66
76
|
result?.message_id,
|
|
67
77
|
now(),
|
|
68
78
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Stickers } from "../stickers/index.js";
|
|
1
2
|
import { settleAftermath } from "../mind/aftermath.js";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { Repository } from "./repository.js";
|
|
@@ -64,6 +65,11 @@ export class ChatSystem {
|
|
|
64
65
|
embedding: this.mind.embedding,
|
|
65
66
|
});
|
|
66
67
|
this.compactor = new ContextCompactor(this.repo);
|
|
68
|
+
this.stickers = new Stickers(this.repo, {
|
|
69
|
+
models: this.models,
|
|
70
|
+
embedding: this.mind.embedding,
|
|
71
|
+
now: () => this.now(),
|
|
72
|
+
});
|
|
67
73
|
this.topics = new TopicTracker(this.repo);
|
|
68
74
|
this.capabilities = new Capabilities(this.repo, { now });
|
|
69
75
|
this.perceivers = new Perceivers(this.repo.db);
|
|
@@ -211,6 +217,14 @@ export class ChatSystem {
|
|
|
211
217
|
this.store.settings().memoryCandidates !== false
|
|
212
218
|
)
|
|
213
219
|
this.mind.memory.remember({ ...m, ...event });
|
|
220
|
+
if (!m.simulated)
|
|
221
|
+
try {
|
|
222
|
+
this.stickers.observe({ ...m, ...event }, (id) =>
|
|
223
|
+
this.mind.bonds.name(id),
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
// A sticker outcome is a side note; never stop a message for it.
|
|
227
|
+
}
|
|
214
228
|
// Its vector is made while the batch gathers; the turn finds it cached.
|
|
215
229
|
if (!m.simulated && this.enabled(event.sessionId, { simulated: false }))
|
|
216
230
|
this.mind.recall.arrive(event.seq, this.now()).catch(() => false);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { typedAt } from "../stickers/offer.js";
|
|
2
|
+
import { persistReply } from "./message-manager.js";
|
|
1
3
|
import { PRIVATELY_KNOWN } from "../mind/discretion.js";
|
|
2
4
|
import { replyFocus } from "./conversation-cues.js";
|
|
3
5
|
|
|
@@ -532,6 +534,19 @@ export class ReplyDelivery {
|
|
|
532
534
|
}
|
|
533
535
|
};
|
|
534
536
|
if (outdated()) return staleExit();
|
|
537
|
+
if (!turn.bubbles.length && turn.sticker && !turn.crisis?.clear) {
|
|
538
|
+
// Only a sticker: nothing to check in words, it was chosen from hers.
|
|
539
|
+
trace.response = { bubbles: [], sticker: turn.sticker.ref };
|
|
540
|
+
trace.sent = [];
|
|
541
|
+
const sent = await this.sendSticker(
|
|
542
|
+
batch.at(-1) || c.anchor,
|
|
543
|
+
turn,
|
|
544
|
+
trace,
|
|
545
|
+
session,
|
|
546
|
+
isCurrent,
|
|
547
|
+
);
|
|
548
|
+
return finish(sent ? "sent" : "cancelled", turn.reason);
|
|
549
|
+
}
|
|
535
550
|
let response = turn.bubbles.length
|
|
536
551
|
? normalizeResponse(
|
|
537
552
|
{ bubbles: turn.bubbles, reason: turn.reason },
|
|
@@ -701,6 +716,10 @@ export class ReplyDelivery {
|
|
|
701
716
|
this.owner.now(),
|
|
702
717
|
snapshot.inner?.currentLife?.current,
|
|
703
718
|
);
|
|
719
|
+
// A rewritten or generated reply may type an @ as text; it is made real.
|
|
720
|
+
const typed = typedAt(response.bubbles, turn.at || [], snapshot);
|
|
721
|
+
if (typed.bubbles.length)
|
|
722
|
+
response = { ...response, bubbles: typed.bubbles };
|
|
704
723
|
trace.response = response;
|
|
705
724
|
trace.sent = await deliver(
|
|
706
725
|
this.owner.repo,
|
|
@@ -712,6 +731,7 @@ export class ReplyDelivery {
|
|
|
712
731
|
{
|
|
713
732
|
now: this.owner.now,
|
|
714
733
|
quote: buriedTarget(batch, turn.targetMessageIds, session),
|
|
734
|
+
at: typed.at,
|
|
715
735
|
prepareBubble: (line) =>
|
|
716
736
|
this.owner.mind.time.reconcile(
|
|
717
737
|
{ bubbles: [line] },
|
|
@@ -722,6 +742,14 @@ export class ReplyDelivery {
|
|
|
722
742
|
},
|
|
723
743
|
);
|
|
724
744
|
if (!trace.sent.length && hasRelevantUpdate()) return staleExit();
|
|
745
|
+
if (trace.sent.length && turn.sticker)
|
|
746
|
+
await this.sendSticker(
|
|
747
|
+
batch.at(-1) || c.anchor,
|
|
748
|
+
turn,
|
|
749
|
+
trace,
|
|
750
|
+
session,
|
|
751
|
+
isCurrent,
|
|
752
|
+
);
|
|
725
753
|
if (trace.sent.length && !c.simulatedTurn) {
|
|
726
754
|
if (turn.share?.workId) {
|
|
727
755
|
try {
|
|
@@ -744,6 +772,50 @@ export class ReplyDelivery {
|
|
|
744
772
|
}
|
|
745
773
|
return finish(trace.sent.length ? "sent" : "cancelled", turn.reason);
|
|
746
774
|
}
|
|
775
|
+
// Her sticker, after her words. It is recorded as something she sent, and
|
|
776
|
+
// the use is kept to learn how it landed with this person.
|
|
777
|
+
async sendSticker(message, turn, trace, session, isCurrent) {
|
|
778
|
+
const owner = this.owner;
|
|
779
|
+
const image = owner.stickers?.library.image(turn.sticker.id);
|
|
780
|
+
if (!image || !isCurrent()) return false;
|
|
781
|
+
try {
|
|
782
|
+
await new Promise((r) => setTimeout(r, owner.stickerPause ?? 600));
|
|
783
|
+
const result = await owner.send(message, "", {
|
|
784
|
+
image: {
|
|
785
|
+
base64: Buffer.from(image.bytes).toString("base64"),
|
|
786
|
+
sticker: true,
|
|
787
|
+
summary: "[动画表情]",
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
const text = `[表情:${turn.sticker.looks}]`;
|
|
791
|
+
persistReply(
|
|
792
|
+
owner.repo,
|
|
793
|
+
{
|
|
794
|
+
...message,
|
|
795
|
+
traceId: trace.id,
|
|
796
|
+
replyTargetIds: turn.targetMessageIds || [],
|
|
797
|
+
},
|
|
798
|
+
text,
|
|
799
|
+
result?.message_id,
|
|
800
|
+
owner.now(),
|
|
801
|
+
);
|
|
802
|
+
(trace.sent ||= []).push(text);
|
|
803
|
+
if (!message?.simulated) {
|
|
804
|
+
const target = (message && (message.userId ?? message.speaker)) || "";
|
|
805
|
+
owner.stickers.used({
|
|
806
|
+
stickerId: turn.sticker.id,
|
|
807
|
+
session,
|
|
808
|
+
userId: target,
|
|
809
|
+
topic: turn.topic || "",
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
trace.steps.push(`发了一张表情:${turn.sticker.looks}`);
|
|
813
|
+
return true;
|
|
814
|
+
} catch (error) {
|
|
815
|
+
trace.steps.push(`表情没有发出去:${error.message}`);
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
747
819
|
}
|
|
748
820
|
|
|
749
821
|
// In a group, when several other people have spoken since the one message
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { mediaSettings } from "../stickers/api.js";
|
|
2
|
+
import { ownerImageLimits } from "./image-preprocessor.js";
|
|
3
|
+
import { atCandidates, expressiveChannel } from "../stickers/offer.js";
|
|
1
4
|
import { speechLength } from "./speech-length.js";
|
|
2
5
|
import { parseSessionKey } from "../channels/session-key.js";
|
|
3
6
|
import { initiativeContext, initiativeSnapshot } from "./initiative-context.js";
|
|
@@ -455,6 +458,27 @@ export class TurnProcessor {
|
|
|
455
458
|
snapshot.inner = view.inner;
|
|
456
459
|
}
|
|
457
460
|
lap("context");
|
|
461
|
+
if (!ownInitiative && this.owner.stickers && expressiveChannel(session)) {
|
|
462
|
+
// A few stickers that could fit, and who she could @; she decides.
|
|
463
|
+
snapshot.canAt = atCandidates(snapshot, session);
|
|
464
|
+
try {
|
|
465
|
+
const last = snapshot.batch.at(-1) || {};
|
|
466
|
+
snapshot.stickers = await this.owner.stickers.candidates({
|
|
467
|
+
session,
|
|
468
|
+
text: snapshot.batch.map((m) => m.text || "").join("\n"),
|
|
469
|
+
mood: view.inner?.state || "",
|
|
470
|
+
addressee: String(last.speaker ?? last.userId ?? ""),
|
|
471
|
+
memories: (snapshot.memories || [])
|
|
472
|
+
.map((m) => m.content || m.text || "")
|
|
473
|
+
.slice(0, 6),
|
|
474
|
+
});
|
|
475
|
+
} catch (error) {
|
|
476
|
+
snapshot.stickers = [];
|
|
477
|
+
trace.steps.push(`表情候选暂不可用:${error.message}`);
|
|
478
|
+
}
|
|
479
|
+
if (!snapshot.canAt.length) delete snapshot.canAt;
|
|
480
|
+
if (!snapshot.stickers.length) delete snapshot.stickers;
|
|
481
|
+
}
|
|
458
482
|
const visionModel = model.vision
|
|
459
483
|
? model
|
|
460
484
|
: this.owner.fallbackFor(null, model).find((item) => item.vision) ||
|
|
@@ -474,6 +498,7 @@ export class TurnProcessor {
|
|
|
474
498
|
? await this.owner.loadVisionImages(early.pending, {
|
|
475
499
|
sessionId: snapshot.sessionId,
|
|
476
500
|
fetchImage: this.owner.fetchImage,
|
|
501
|
+
imageLimits: ownerImageLimits(mediaSettings(this.owner.repo)),
|
|
477
502
|
})
|
|
478
503
|
: { images: [], unavailable: [] };
|
|
479
504
|
const planned = classifyLoaded(
|
package/server/core/turn.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chosenAt,
|
|
3
|
+
chosenSticker,
|
|
4
|
+
expressionInput,
|
|
5
|
+
typedAt,
|
|
6
|
+
} from "../stickers/offer.js";
|
|
1
7
|
import { replyFocus } from "./conversation-cues.js";
|
|
2
8
|
import { INITIATIVE_PROMPT } from "../mind/initiative.js";
|
|
3
9
|
import { initiativeContext } from "./initiative-context.js";
|
|
@@ -159,6 +165,7 @@ export async function takeTurn(
|
|
|
159
165
|
...(recallWording(snapshot) ? { guidance: recallWording(snapshot) } : {}),
|
|
160
166
|
...(!initiating ? { exchange: currentExchange(snapshot) } : {}),
|
|
161
167
|
...turnGuides(snapshot),
|
|
168
|
+
...expressionInput(snapshot),
|
|
162
169
|
};
|
|
163
170
|
let raw = await models.call(
|
|
164
171
|
profile,
|
|
@@ -564,8 +571,8 @@ export function normalizeTurn(raw, snapshot, trace, { speech } = {}) {
|
|
|
564
571
|
.slice(0, 4),
|
|
565
572
|
crisis: crisis ? { clear: true, messageIds: crisisIds } : { clear: false },
|
|
566
573
|
act: botYield || stopped ? null : normalizeAct(raw.act, trace),
|
|
567
|
-
bubbles: bubbles.slice(0, bubbleLimit),
|
|
568
574
|
maxBubbles: bubbleLimit,
|
|
575
|
+
...expressionChoice(raw, snapshot, choice, bubbles.slice(0, bubbleLimit)),
|
|
569
576
|
};
|
|
570
577
|
}
|
|
571
578
|
|
|
@@ -639,3 +646,20 @@ export function typedSends(text) {
|
|
|
639
646
|
}
|
|
640
647
|
return sends;
|
|
641
648
|
}
|
|
649
|
+
|
|
650
|
+
// A sticker and @s are part of what she chose, kept only when they point at
|
|
651
|
+
// something she was actually offered this turn.
|
|
652
|
+
function expressionChoice(raw, snapshot, choice, bubbles) {
|
|
653
|
+
if (!["speak", "react"].includes(choice)) return { bubbles };
|
|
654
|
+
const sticker = chosenSticker(raw.sticker, snapshot);
|
|
655
|
+
const typed = typedAt(
|
|
656
|
+
bubbles,
|
|
657
|
+
chosenAt(raw.at, snapshot, snapshot.sessionId, ids(raw.targetMessageIds)),
|
|
658
|
+
snapshot,
|
|
659
|
+
);
|
|
660
|
+
return {
|
|
661
|
+
bubbles: typed.bubbles,
|
|
662
|
+
...(sticker ? { sticker } : {}),
|
|
663
|
+
...(typed.at.length ? { at: typed.at } : {}),
|
|
664
|
+
};
|
|
665
|
+
}
|
package/server/i18n/errors.js
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
// key, exactly as the code writes it. `{0}` stands for a value the code puts
|
|
3
3
|
// into the message; patterns are used when a message carries one.
|
|
4
4
|
export const ERRORS = {
|
|
5
|
+
表情开关无效: "Invalid sticker switch",
|
|
6
|
+
"图片理解尺寸应在 512–8192 像素之间":
|
|
7
|
+
"Image understanding size must be between 512 and 8192 pixels",
|
|
8
|
+
"图片理解大小应在 0.5–4 MB 之间":
|
|
9
|
+
"Image understanding size must be between 0.5 and 4 MB",
|
|
10
|
+
"可接收的图片大小应在 1–64 MB 之间":
|
|
11
|
+
"The accepted image size must be between 1 and 64 MB",
|
|
12
|
+
表情不存在: "The sticker does not exist",
|
|
13
|
+
文件为空: "The file is empty",
|
|
14
|
+
"表情超过 2MB,请压缩后再上传":
|
|
15
|
+
"The sticker is larger than 2 MB; compress it and upload again",
|
|
16
|
+
"只支持 PNG、JPEG、GIF、WebP 图片":
|
|
17
|
+
"Only PNG, JPEG, GIF and WebP images are supported",
|
|
18
|
+
"表情尺寸应在 4096 像素以内": "Stickers must be within 4096 pixels",
|
|
5
19
|
复审超过截止时间: "The reply review passed its deadline",
|
|
6
20
|
"JSON 同一对象含重复字段,不能确定哪份内容有效":
|
|
7
21
|
"A JSON object contains duplicate fields; the intended value is ambiguous",
|
|
@@ -382,6 +396,7 @@ export const ERRORS = {
|
|
|
382
396
|
"插件不支持这个 LuckyTri 版本":
|
|
383
397
|
"The plugin does not support this version of LuckyTri",
|
|
384
398
|
插件网络主机无效: "A plugin network host is invalid",
|
|
399
|
+
压缩包过大: "The archive is too large",
|
|
385
400
|
压缩包无法读取: "The archive could not be read",
|
|
386
401
|
压缩包文件过多: "The archive has too many files",
|
|
387
402
|
压缩包不能加密: "The archive must not be encrypted",
|
package/server/plugins/zip.js
CHANGED
|
@@ -27,7 +27,19 @@ function safeName(name) {
|
|
|
27
27
|
|
|
28
28
|
// Reads a zip the way a stranger's file has to be read: only stored and
|
|
29
29
|
// deflate entries, no encryption, no links, no paths that leave the folder.
|
|
30
|
-
|
|
30
|
+
//
|
|
31
|
+
// Options let another caller (the sticker library) take larger archives:
|
|
32
|
+
// maxFiles / maxBytes bound the whole archive; keep(name) skips an entry
|
|
33
|
+
// without inflating it; an entry over maxEntryBytes is reported, not read.
|
|
34
|
+
export function readZip(
|
|
35
|
+
buffer,
|
|
36
|
+
{
|
|
37
|
+
maxFiles = MAX_FILES,
|
|
38
|
+
maxBytes = MAX_BYTES,
|
|
39
|
+
maxEntryBytes = Infinity,
|
|
40
|
+
keep = () => true,
|
|
41
|
+
} = {},
|
|
42
|
+
) {
|
|
31
43
|
if (!Buffer.isBuffer(buffer) || buffer.length < 22)
|
|
32
44
|
throw new Error("压缩包无法读取");
|
|
33
45
|
const eocd = findEocd(buffer);
|
|
@@ -35,7 +47,7 @@ export function readZip(buffer) {
|
|
|
35
47
|
if (buffer.readUInt16LE(eocd + 4) !== 0)
|
|
36
48
|
throw new Error("压缩包格式不受支持");
|
|
37
49
|
const entries = buffer.readUInt16LE(eocd + 10);
|
|
38
|
-
if (!entries || entries >
|
|
50
|
+
if (!entries || entries > maxFiles) throw new Error("压缩包文件过多");
|
|
39
51
|
let offset = buffer.readUInt32LE(eocd + 16);
|
|
40
52
|
if (offset >= buffer.length) throw new Error("压缩包无法读取");
|
|
41
53
|
const files = [];
|
|
@@ -68,6 +80,11 @@ export function readZip(buffer) {
|
|
|
68
80
|
if (method !== 0 && method !== 8) throw new Error("压缩包格式不受支持");
|
|
69
81
|
if (name.endsWith("/") || name.endsWith("\\")) continue;
|
|
70
82
|
const fileName = safeName(name);
|
|
83
|
+
if (!keep(fileName)) continue;
|
|
84
|
+
if (size > maxEntryBytes) {
|
|
85
|
+
files.push({ name: fileName, skipped: "too-large" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
71
88
|
if (buffer.readUInt32LE(local) !== 0x04034b50)
|
|
72
89
|
throw new Error("压缩包无法读取");
|
|
73
90
|
const dataAt =
|
|
@@ -84,16 +101,27 @@ export function readZip(buffer) {
|
|
|
84
101
|
method === 0
|
|
85
102
|
? Buffer.from(compressed)
|
|
86
103
|
: inflateRawSync(compressed, {
|
|
87
|
-
maxOutputLength:
|
|
104
|
+
maxOutputLength: Math.min(maxBytes - total, maxEntryBytes) + 1,
|
|
88
105
|
});
|
|
89
106
|
} catch (error) {
|
|
107
|
+
if (
|
|
108
|
+
error?.code === "ERR_BUFFER_TOO_LARGE" &&
|
|
109
|
+
maxEntryBytes < maxBytes - total
|
|
110
|
+
) {
|
|
111
|
+
files.push({ name: fileName, skipped: "too-large" });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
90
114
|
if (error?.code === "ERR_BUFFER_TOO_LARGE")
|
|
91
115
|
throw new Error("压缩包解压后过大");
|
|
92
116
|
throw new Error("压缩包无法读取");
|
|
93
117
|
}
|
|
94
118
|
if (size && bytes.length !== size) throw new Error("压缩包内容不完整");
|
|
119
|
+
if (bytes.length > maxEntryBytes) {
|
|
120
|
+
files.push({ name: fileName, skipped: "too-large" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
95
123
|
total += bytes.length;
|
|
96
|
-
if (total >
|
|
124
|
+
if (total > maxBytes) throw new Error("压缩包解压后过大");
|
|
97
125
|
files.push({ name: fileName, bytes });
|
|
98
126
|
}
|
|
99
127
|
return files;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { importStickerZip, stickerZipLimitMb } from "./zip-import.js";
|
|
3
|
+
|
|
4
|
+
// The owner's sticker library. Uploads arrive as raw bytes, one file per
|
|
5
|
+
// request; everything else is JSON like the rest of the studio API.
|
|
6
|
+
export function mountStickerUpload(app, system) {
|
|
7
|
+
app.post(
|
|
8
|
+
"/api/core/stickers/upload",
|
|
9
|
+
express.raw({ type: "*/*", limit: "3mb" }),
|
|
10
|
+
async (req, res) => {
|
|
11
|
+
try {
|
|
12
|
+
const item = await system.stickers.library.add(req.body, {
|
|
13
|
+
name: String(req.query.name || "").replace(/\.[a-z0-9]+$/i, ""),
|
|
14
|
+
category: String(req.query.category || ""),
|
|
15
|
+
});
|
|
16
|
+
if (!item.duplicate) queueDescribe(system, item.id);
|
|
17
|
+
res.json(item);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
res.status(400).json({ error: error.message });
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
app.post(
|
|
24
|
+
"/api/core/stickers/upload-zip",
|
|
25
|
+
express.raw({ type: "*/*", limit: `${stickerZipLimitMb()}mb` }),
|
|
26
|
+
async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
res.json(
|
|
29
|
+
await importStickerZip(system.stickers.library, req.body, {
|
|
30
|
+
category: String(req.query.category || ""),
|
|
31
|
+
folderCategory: req.query.folderCategory === "1",
|
|
32
|
+
queue: (id) => queueDescribe(system, id),
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
res.status(400).json({ error: error.message });
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const queue = [];
|
|
43
|
+
let running = false;
|
|
44
|
+
let current = null; // the one being analysed right now still counts as pending
|
|
45
|
+
function queueDescribe(system, id) {
|
|
46
|
+
queue.push(id);
|
|
47
|
+
if (running) return;
|
|
48
|
+
running = true;
|
|
49
|
+
(async () => {
|
|
50
|
+
while (queue.length) {
|
|
51
|
+
const next = queue.shift();
|
|
52
|
+
current = next;
|
|
53
|
+
try {
|
|
54
|
+
await system.stickers.describe(next, { force: true });
|
|
55
|
+
} catch {
|
|
56
|
+
// The item records its own failure; the owner can retry.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
current = null;
|
|
60
|
+
running = false;
|
|
61
|
+
})();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MEDIA_DEFAULTS = {
|
|
65
|
+
visionMaxSide: 2048,
|
|
66
|
+
visionMaxMB: 3,
|
|
67
|
+
visionSourceMaxMB: 20,
|
|
68
|
+
};
|
|
69
|
+
export function mediaSettings(repo) {
|
|
70
|
+
return { ...MEDIA_DEFAULTS, ...(repo.config("media") || {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function mountStickers(app, system) {
|
|
74
|
+
const { library } = system.stickers;
|
|
75
|
+
const wrap = (fn) => async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
await fn(req, res);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
res.status(400).json({ error: error.message });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
app.get(
|
|
83
|
+
"/api/core/stickers",
|
|
84
|
+
wrap((req, res) =>
|
|
85
|
+
res.json({
|
|
86
|
+
items: library.list({
|
|
87
|
+
q: req.query.q || "",
|
|
88
|
+
category: req.query.category || "",
|
|
89
|
+
}),
|
|
90
|
+
categories: library.categories(),
|
|
91
|
+
settings: system.stickers.settings(),
|
|
92
|
+
pending: queue.length + (current ? 1 : 0),
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
app.get(
|
|
97
|
+
"/api/core/stickers/:id/image",
|
|
98
|
+
wrap((req, res) => {
|
|
99
|
+
const image = library.image(req.params.id);
|
|
100
|
+
if (!image) return res.status(404).json({ error: "表情不存在" });
|
|
101
|
+
res.setHeader("Content-Type", image.mime);
|
|
102
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
103
|
+
res.setHeader("Cache-Control", "private, max-age=86400");
|
|
104
|
+
res.end(Buffer.from(image.bytes));
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
app.patch(
|
|
108
|
+
"/api/core/stickers/:id",
|
|
109
|
+
wrap((req, res) => res.json(library.update(req.params.id, req.body))),
|
|
110
|
+
);
|
|
111
|
+
app.delete(
|
|
112
|
+
"/api/core/stickers/:id",
|
|
113
|
+
wrap((req, res) => {
|
|
114
|
+
library.remove(req.params.id);
|
|
115
|
+
res.json({ ok: true });
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
app.post(
|
|
119
|
+
"/api/core/stickers/:id/describe",
|
|
120
|
+
wrap((req, res) => {
|
|
121
|
+
if (!library.get(req.params.id)) throw Error("表情不存在");
|
|
122
|
+
library.update(req.params.id, { status: "pending" });
|
|
123
|
+
queueDescribe(system, req.params.id);
|
|
124
|
+
res.json({ ok: true });
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
app.put(
|
|
128
|
+
"/api/core/stickers-settings",
|
|
129
|
+
wrap((req, res) => {
|
|
130
|
+
const { enabled, taggingModelId } = req.body;
|
|
131
|
+
if (enabled !== undefined && typeof enabled !== "boolean")
|
|
132
|
+
throw Error("表情开关无效");
|
|
133
|
+
system.repo.saveConfig("stickers", {
|
|
134
|
+
...system.stickers.settings(),
|
|
135
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
136
|
+
...(taggingModelId !== undefined
|
|
137
|
+
? { taggingModelId: String(taggingModelId || "") }
|
|
138
|
+
: {}),
|
|
139
|
+
});
|
|
140
|
+
res.json(system.stickers.settings());
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
app.get(
|
|
144
|
+
"/api/core/media-settings",
|
|
145
|
+
wrap((req, res) => res.json(mediaSettings(system.repo))),
|
|
146
|
+
);
|
|
147
|
+
app.put(
|
|
148
|
+
"/api/core/media-settings",
|
|
149
|
+
wrap((req, res) => {
|
|
150
|
+
const side = Number(req.body.visionMaxSide);
|
|
151
|
+
const mb = Number(req.body.visionMaxMB);
|
|
152
|
+
const source = Number(
|
|
153
|
+
req.body.visionSourceMaxMB ??
|
|
154
|
+
mediaSettings(system.repo).visionSourceMaxMB,
|
|
155
|
+
);
|
|
156
|
+
if (!Number.isFinite(source) || source < 1 || source > 64)
|
|
157
|
+
throw Error("可接收的图片大小应在 1–64 MB 之间");
|
|
158
|
+
if (!Number.isInteger(side) || side < 512 || side > 8192)
|
|
159
|
+
throw Error("图片理解尺寸应在 512–8192 像素之间");
|
|
160
|
+
if (!Number.isFinite(mb) || mb < 0.5 || mb > 4)
|
|
161
|
+
throw Error("图片理解大小应在 0.5–4 MB 之间");
|
|
162
|
+
system.repo.saveConfig("media", {
|
|
163
|
+
visionMaxSide: side,
|
|
164
|
+
visionMaxMB: mb,
|
|
165
|
+
visionSourceMaxMB: source,
|
|
166
|
+
});
|
|
167
|
+
res.json(mediaSettings(system.repo));
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|