cortico 0.1.0
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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +43 -0
- package/src/boot.ts +46 -0
- package/src/bot.ts +1342 -0
- package/src/config-file.ts +41 -0
- package/src/core/README.md +92 -0
- package/src/core/billing.ts +26 -0
- package/src/core/blobs.ts +106 -0
- package/src/core/bus.ts +311 -0
- package/src/core/config-schema.ts +201 -0
- package/src/core/config.ts +262 -0
- package/src/core/core.ts +699 -0
- package/src/core/cost.ts +244 -0
- package/src/core/event-store.ts +304 -0
- package/src/core/fork.ts +113 -0
- package/src/core/generation.ts +118 -0
- package/src/core/instance-lock.ts +215 -0
- package/src/core/ipc-logger.ts +105 -0
- package/src/core/language.ts +62 -0
- package/src/core/log-context.ts +38 -0
- package/src/core/loop.ts +1858 -0
- package/src/core/markers.ts +48 -0
- package/src/core/prefix.ts +121 -0
- package/src/core/run.ts +95 -0
- package/src/core/secrets.ts +18 -0
- package/src/core/session.ts +56 -0
- package/src/core/sessions.ts +261 -0
- package/src/core/state.ts +104 -0
- package/src/core/template.ts +72 -0
- package/src/core/timers.ts +129 -0
- package/src/core/tool-log.ts +135 -0
- package/src/core/transcript.ts +92 -0
- package/src/core/truncate.ts +121 -0
- package/src/core/types.ts +1140 -0
- package/src/core/usage-log.ts +79 -0
- package/src/core/util.ts +386 -0
- package/src/deploy-listing.ts +160 -0
- package/src/deploy.ts +162 -0
- package/src/extensions/README.md +72 -0
- package/src/extensions/dry-mount.ts +390 -0
- package/src/extensions/manifest.ts +153 -0
- package/src/extensions/runtime.ts +42 -0
- package/src/extensions.ts +519 -0
- package/src/launcher.ts +285 -0
- package/src/paths.ts +155 -0
- package/src/protocol/open-responses/LICENSE +201 -0
- package/src/protocol/open-responses/README.md +14 -0
- package/src/protocol/open-responses/context-helpers.ts +27 -0
- package/src/protocol/open-responses/context-log.ts +59 -0
- package/src/protocol/open-responses/context.ts +89 -0
- package/src/protocol/open-responses/generated.ts +115 -0
- package/src/protocol/open-responses/index.ts +33 -0
- package/src/protocol/open-responses/openapi.json +4230 -0
- package/src/protocol/open-responses/stream.ts +146 -0
- package/src/protocol/open-responses/tokens.ts +31 -0
- package/src/providers/README.md +86 -0
- package/src/providers/base.ts +111 -0
- package/src/providers/configuration.ts +97 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/settings.ts +443 -0
- package/src/providers/console/strings.ts +74 -0
- package/src/providers/console/types.ts +11 -0
- package/src/providers/llamacpp/archive.ts +148 -0
- package/src/providers/llamacpp/catalog.ts +247 -0
- package/src/providers/llamacpp/config.ts +36 -0
- package/src/providers/llamacpp/console/client.ts +6 -0
- package/src/providers/llamacpp/console/models-panel.ts +120 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
- package/src/providers/llamacpp/console/server.ts +153 -0
- package/src/providers/llamacpp/index.ts +95 -0
- package/src/providers/llamacpp/native.ts +51 -0
- package/src/providers/llamacpp/options.ts +117 -0
- package/src/providers/llamacpp/runtime-store.ts +139 -0
- package/src/providers/llamacpp/runtime.ts +214 -0
- package/src/providers/llamacpp/server.ts +286 -0
- package/src/providers/llamacpp/strings.ts +230 -0
- package/src/providers/openai-responses-compat/config.ts +20 -0
- package/src/providers/openai-responses-compat/index.ts +89 -0
- package/src/providers/openai-responses-compat/native.ts +127 -0
- package/src/providers/openai-responses-compat/strings.ts +19 -0
- package/src/providers/pricebook.ts +75 -0
- package/src/providers/registry.ts +137 -0
- package/src/providers/strings.ts +71 -0
- package/src/providers/transport/chat.ts +156 -0
- package/src/providers/transport/errors.ts +48 -0
- package/src/providers/transport/history.ts +68 -0
- package/src/providers/transport/native-input.ts +83 -0
- package/src/providers/transport/native-types.ts +13 -0
- package/src/providers/transport/response-assembly.ts +191 -0
- package/src/providers/transport/response-http.ts +196 -0
- package/src/providers/transport/response-meters.ts +27 -0
- package/src/providers/transport/responses-input.ts +56 -0
- package/src/web/README.md +84 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
- package/src/web/client/console-pages/builtins.ts +10 -0
- package/src/web/client/console-pages/context.ts +135 -0
- package/src/web/client/console-pages/host.ts +633 -0
- package/src/web/client/console-pages/loader.ts +132 -0
- package/src/web/client/console-pages/strings.ts +90 -0
- package/src/web/client/console-pages/tools/strings.ts +49 -0
- package/src/web/client/console-pages/tools/view.ts +215 -0
- package/src/web/client/core/api.ts +200 -0
- package/src/web/client/core/language.ts +56 -0
- package/src/web/client/core/lifecycle.ts +132 -0
- package/src/web/client/core/router.ts +199 -0
- package/src/web/client/core/stream.ts +157 -0
- package/src/web/client/core/websocket.ts +82 -0
- package/src/web/client/features/appearance/index.ts +333 -0
- package/src/web/client/features/appearance/strings.ts +87 -0
- package/src/web/client/features/config/index.ts +11 -0
- package/src/web/client/features/config/strings.ts +41 -0
- package/src/web/client/features/config/view.ts +428 -0
- package/src/web/client/features/core/events.ts +197 -0
- package/src/web/client/features/core/index.ts +254 -0
- package/src/web/client/features/core/run.ts +56 -0
- package/src/web/client/features/core/runlog.ts +214 -0
- package/src/web/client/features/core/sessions.ts +53 -0
- package/src/web/client/features/core/strings.ts +157 -0
- package/src/web/client/features/extensions/index.ts +422 -0
- package/src/web/client/features/extensions/strings.ts +164 -0
- package/src/web/client/features/feature.ts +93 -0
- package/src/web/client/features/live/context.ts +280 -0
- package/src/web/client/features/live/fork.ts +156 -0
- package/src/web/client/features/live/index.ts +392 -0
- package/src/web/client/features/live/onboarding.ts +113 -0
- package/src/web/client/features/live/protocol.ts +122 -0
- package/src/web/client/features/live/sessions.ts +71 -0
- package/src/web/client/features/live/status.ts +68 -0
- package/src/web/client/features/live/strings.ts +252 -0
- package/src/web/client/features/live/timeline.ts +590 -0
- package/src/web/client/features/prompts/editor.ts +292 -0
- package/src/web/client/features/prompts/index.ts +346 -0
- package/src/web/client/features/prompts/strings.ts +136 -0
- package/src/web/client/features/prompts/view.ts +256 -0
- package/src/web/client/features/providers/index.ts +100 -0
- package/src/web/client/features/providers/strings.ts +23 -0
- package/src/web/client/features/settings/general.ts +28 -0
- package/src/web/client/features/settings/index.ts +82 -0
- package/src/web/client/features/settings/strings.ts +33 -0
- package/src/web/client/features/storage/index.ts +1 -0
- package/src/web/client/features/storage/strings.ts +45 -0
- package/src/web/client/features/storage/view.ts +154 -0
- package/src/web/client/features/usage/chart.ts +486 -0
- package/src/web/client/features/usage/index.ts +407 -0
- package/src/web/client/features/usage/labels.ts +46 -0
- package/src/web/client/features/usage/range.ts +36 -0
- package/src/web/client/features/usage/state.ts +37 -0
- package/src/web/client/features/usage/strings.ts +191 -0
- package/src/web/client/features/usage/tooltip.ts +86 -0
- package/src/web/client/features/usage/types.ts +96 -0
- package/src/web/client/features/worlds/index.ts +424 -0
- package/src/web/client/features/worlds/strings.ts +121 -0
- package/src/web/client/main.ts +273 -0
- package/src/web/client/shell/avatar.ts +233 -0
- package/src/web/client/shell/index.ts +549 -0
- package/src/web/client/shell/strings.ts +89 -0
- package/src/web/client/theme/handoff.ts +43 -0
- package/src/web/client/theme/palette.ts +109 -0
- package/src/web/client/theme/registry.ts +130 -0
- package/src/web/client/theme/storage.ts +72 -0
- package/src/web/client/theme/strings.ts +123 -0
- package/src/web/client/theme/studio.ts +366 -0
- package/src/web/client/ui/actions.ts +109 -0
- package/src/web/client/ui/data.ts +185 -0
- package/src/web/client/ui/dom.ts +23 -0
- package/src/web/client/ui/fields.ts +191 -0
- package/src/web/client/ui/format.ts +75 -0
- package/src/web/client/ui/icons.ts +183 -0
- package/src/web/client/ui/images.ts +91 -0
- package/src/web/client/ui/index.ts +89 -0
- package/src/web/client/ui/lamp.ts +125 -0
- package/src/web/client/ui/log.ts +98 -0
- package/src/web/client/ui/overlay.ts +184 -0
- package/src/web/client/ui/page.ts +8 -0
- package/src/web/client/ui/prompt-input.tsx +311 -0
- package/src/web/client/ui/sheet.ts +131 -0
- package/src/web/client/ui/strings.ts +71 -0
- package/src/web/console-pages.ts +367 -0
- package/src/web/files.ts +107 -0
- package/src/web/path-picker.ts +296 -0
- package/src/web/public/index.html +17 -0
- package/src/web/public/styles.css +1715 -0
- package/src/web/server.ts +1883 -0
- package/src/web/shared/client-panel.ts +603 -0
- package/src/web/shared/console-protocol.ts +560 -0
- package/src/web/shared/css.d.ts +1 -0
- package/src/web/shared/path-picker.ts +23 -0
- package/src/web/shared/theme.ts +256 -0
- package/src/web/theme-store.ts +37 -0
- package/src/world.ts +449 -0
- package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
- package/src/worlds/bilibili/README.md +176 -0
- package/src/worlds/bilibili/audience-admission.ts +995 -0
- package/src/worlds/bilibili/client.ts +528 -0
- package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
- package/src/worlds/bilibili/config.ts +185 -0
- package/src/worlds/bilibili/console/client.ts +202 -0
- package/src/worlds/bilibili/definition.ts +35 -0
- package/src/worlds/bilibili/gift-frame.ts +131 -0
- package/src/worlds/bilibili/normalize.ts +452 -0
- package/src/worlds/bilibili/overlay/announcement.ts +76 -0
- package/src/worlds/bilibili/overlay/assets.ts +82 -0
- package/src/worlds/bilibili/overlay/model.ts +494 -0
- package/src/worlds/bilibili/overlay/project.ts +202 -0
- package/src/worlds/bilibili/overlay/server.ts +286 -0
- package/src/worlds/bilibili/overlay/types.ts +217 -0
- package/src/worlds/bilibili/overlay/web/app.js +690 -0
- package/src/worlds/bilibili/overlay/web/editor.css +297 -0
- package/src/worlds/bilibili/overlay/web/editor.html +102 -0
- package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
- package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
- package/src/worlds/bilibili/overlay/web/styles.css +258 -0
- package/src/worlds/bilibili/protobuf.ts +123 -0
- package/src/worlds/bilibili/wire.ts +94 -0
- package/src/worlds/bilibili/world.ts +1389 -0
- package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
- package/src/worlds/console-fixture/console/client.ts +72 -0
- package/src/worlds/console-fixture/world.ts +73 -0
- package/src/worlds/index.ts +15 -0
- package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
- package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
- package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
- package/src/worlds/minecraft/README.md +917 -0
- package/src/worlds/minecraft/blueprint-plan.ts +915 -0
- package/src/worlds/minecraft/blueprint-registry.ts +566 -0
- package/src/worlds/minecraft/blueprint-repair.ts +236 -0
- package/src/worlds/minecraft/blueprint-resource.ts +272 -0
- package/src/worlds/minecraft/blueprint.ts +492 -0
- package/src/worlds/minecraft/body-lease.ts +340 -0
- package/src/worlds/minecraft/bridge.ts +749 -0
- package/src/worlds/minecraft/check.ts +651 -0
- package/src/worlds/minecraft/chests.ts +292 -0
- package/src/worlds/minecraft/client-launch.ts +272 -0
- package/src/worlds/minecraft/client-options.ts +85 -0
- package/src/worlds/minecraft/client-skins.ts +164 -0
- package/src/worlds/minecraft/client-window.ps1 +91 -0
- package/src/worlds/minecraft/client.ts +423 -0
- package/src/worlds/minecraft/combat-context.ts +109 -0
- package/src/worlds/minecraft/combat.ts +1552 -0
- package/src/worlds/minecraft/config.ts +499 -0
- package/src/worlds/minecraft/console/access.ts +218 -0
- package/src/worlds/minecraft/console/client.ts +244 -0
- package/src/worlds/minecraft/console/log.ts +110 -0
- package/src/worlds/minecraft/console/mount.ts +420 -0
- package/src/worlds/minecraft/console/skin.ts +257 -0
- package/src/worlds/minecraft/console/style.css +154 -0
- package/src/worlds/minecraft/console/world.ts +449 -0
- package/src/worlds/minecraft/deaths.ts +135 -0
- package/src/worlds/minecraft/definition.ts +18 -0
- package/src/worlds/minecraft/engine-child.ts +274 -0
- package/src/worlds/minecraft/engine-ipc.ts +98 -0
- package/src/worlds/minecraft/entity-facts.ts +132 -0
- package/src/worlds/minecraft/escape.ts +407 -0
- package/src/worlds/minecraft/executor.ts +14494 -0
- package/src/worlds/minecraft/explored.ts +156 -0
- package/src/worlds/minecraft/geometry.ts +205 -0
- package/src/worlds/minecraft/goal-plan.ts +647 -0
- package/src/worlds/minecraft/item-break.ts +126 -0
- package/src/worlds/minecraft/item-facts.ts +146 -0
- package/src/worlds/minecraft/item-pick.ts +67 -0
- package/src/worlds/minecraft/level-dat.ts +176 -0
- package/src/worlds/minecraft/log.ts +132 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
- package/src/worlds/minecraft/names.ts +377 -0
- package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
- package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
- package/src/worlds/minecraft/piglin.ts +49 -0
- package/src/worlds/minecraft/policy.ts +547 -0
- package/src/worlds/minecraft/precheck.ts +655 -0
- package/src/worlds/minecraft/proxy.ts +445 -0
- package/src/worlds/minecraft/ranged.ts +635 -0
- package/src/worlds/minecraft/readouts.ts +102 -0
- package/src/worlds/minecraft/round.ts +26 -0
- package/src/worlds/minecraft/search-observation.ts +92 -0
- package/src/worlds/minecraft/server-config.ts +467 -0
- package/src/worlds/minecraft/server.ts +658 -0
- package/src/worlds/minecraft/show.ts +84 -0
- package/src/worlds/minecraft/skills.ts +1763 -0
- package/src/worlds/minecraft/terrain.ts +1735 -0
- package/src/worlds/minecraft/window.ts +96 -0
- package/src/worlds/minecraft/works.ts +179 -0
- package/src/worlds/minecraft/world.ts +6438 -0
- package/src/worlds/qq/ENV_PROMPT.md +5 -0
- package/src/worlds/qq/config.ts +108 -0
- package/src/worlds/qq/console/client.ts +126 -0
- package/src/worlds/qq/console/events.ts +155 -0
- package/src/worlds/qq/console/gate.ts +232 -0
- package/src/worlds/qq/console/roster.ts +203 -0
- package/src/worlds/qq/console/style.css +65 -0
- package/src/worlds/qq/conversation.ts +28 -0
- package/src/worlds/qq/definition.ts +62 -0
- package/src/worlds/qq/driver.ts +375 -0
- package/src/worlds/qq/history-tools.ts +209 -0
- package/src/worlds/qq/normalize.ts +323 -0
- package/src/worlds/qq/qface-map.ts +282 -0
- package/src/worlds/qq/vision-prompt.ts +27 -0
- package/src/worlds/qq/vision.ts +642 -0
- package/src/worlds/qq/vlm.ts +142 -0
- package/src/worlds/qq/world.ts +1503 -0
- package/src/worlds/terminal/ENV_PROMPT.md +11 -0
- package/src/worlds/terminal/config.ts +9 -0
- package/src/worlds/terminal/definition.ts +14 -0
- package/src/worlds/terminal/world.ts +700 -0
- package/src/worlds/websearch/ENV_PROMPT.md +1 -0
- package/src/worlds/websearch/brave-client.ts +261 -0
- package/src/worlds/websearch/config.ts +75 -0
- package/src/worlds/websearch/definition.ts +20 -0
- package/src/worlds/websearch/world.ts +171 -0
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
/** B 站直播间接入与本机 Overlay。直播间协议仅入站;公告工具写本机公告栏,Overlay 通过回环 HTTP 服务供 OBS 读取。 */
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { appendFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import type {
|
|
7
|
+
CandidateProjector,
|
|
8
|
+
ConfigGroup,
|
|
9
|
+
World,
|
|
10
|
+
WorldHost,
|
|
11
|
+
WorldConsoleDecl,
|
|
12
|
+
WorldPanelDecl,
|
|
13
|
+
ShutdownExternalCheck,
|
|
14
|
+
ToolDef,
|
|
15
|
+
} from '../../core/types.ts';
|
|
16
|
+
import { nowIso } from '../../core/util.ts';
|
|
17
|
+
import {
|
|
18
|
+
AudienceAdmission,
|
|
19
|
+
type AudienceAdmissionCandidate,
|
|
20
|
+
type AudienceAdmissionTuning,
|
|
21
|
+
type ImportantAudienceParticipant,
|
|
22
|
+
} from './audience-admission.ts';
|
|
23
|
+
import { LiveClient, type LivePhase, type LiveStatus } from './client.ts';
|
|
24
|
+
import { CoalescingBuffer, type CoalescingGroup } from './coalescing-buffer.ts';
|
|
25
|
+
import { giftFrameData } from './gift-frame.ts';
|
|
26
|
+
import { normalize, type CountField, type GaugeField, type LiveEvent } from './normalize.ts';
|
|
27
|
+
import { AgentAnnouncementStore } from './overlay/announcement.ts';
|
|
28
|
+
import { OverlayAssetStore } from './overlay/assets.ts';
|
|
29
|
+
import {
|
|
30
|
+
BILIBILI_OVERLAY_DEFAULTS,
|
|
31
|
+
BUILTIN_OVERLAY_STYLES,
|
|
32
|
+
cloneOverlayConfig,
|
|
33
|
+
matchAudienceGroup,
|
|
34
|
+
normalizeOverlayConfig,
|
|
35
|
+
normalizeOverlayDesign,
|
|
36
|
+
} from './overlay/model.ts';
|
|
37
|
+
import { projectOverlayEvent } from './overlay/project.ts';
|
|
38
|
+
import { BilibiliOverlayServer, OverlayEditorConflictError } from './overlay/server.ts';
|
|
39
|
+
import type { BilibiliOverlayConfig, OverlayAudienceEvent } from './overlay/types.ts';
|
|
40
|
+
import { BILIBILI_DEFAULTS, BILIBILI_CONFIG_GROUP } from './config.ts';
|
|
41
|
+
|
|
42
|
+
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
43
|
+
|
|
44
|
+
const PANEL_LOG = 'log';
|
|
45
|
+
|
|
46
|
+
/** 开发控制台复用此面板声明。 */
|
|
47
|
+
export const BILIBILI_PANEL_DECLS: readonly WorldPanelDecl[] = [
|
|
48
|
+
{
|
|
49
|
+
id: PANEL_LOG,
|
|
50
|
+
title: '直播间事件',
|
|
51
|
+
description: '直播间事件与各 cmd 计数。',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const RECENT_CAP = 200;
|
|
56
|
+
/** 最近弹幕的观众 uid 检查窗口。 */
|
|
57
|
+
const ANON_WINDOW = 20;
|
|
58
|
+
|
|
59
|
+
const COALESCE_LOG_WINDOW_MS = 60_000;
|
|
60
|
+
/** 延迟渲染的队列项被清除后不会复位 armedAt;超过此时限允许重新排队。 */
|
|
61
|
+
const ARM_STALE_MS = 300_000;
|
|
62
|
+
|
|
63
|
+
/** 名单内命令逐条采样;SEND_GIFT 另按礼物名采样,见 maybeSampleRaw。 */
|
|
64
|
+
const RAW_SAMPLE_DEFAULT_CMDS: readonly string[] = [
|
|
65
|
+
'GUARD_BUY',
|
|
66
|
+
'USER_TOAST_MSG',
|
|
67
|
+
'USER_TOAST_MSG_V2',
|
|
68
|
+
'SUPER_CHAT_MESSAGE',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const SEEN_GIFT_NAMES_CAP = 512;
|
|
72
|
+
|
|
73
|
+
const UNNAMED_GIFT_SAMPLE_CAP = 16;
|
|
74
|
+
|
|
75
|
+
/** GUARD_BUY 始终投递;同 uid、等级不冲突的后续 TOAST 在窗口内去重。 */
|
|
76
|
+
const GUARD_DEDUP_WINDOW_MS = 8_000;
|
|
77
|
+
|
|
78
|
+
/** WS 与轮询共用状态变化去重窗口;窗口长于轮询间隔。 */
|
|
79
|
+
const ROOM_EDGE_DEDUP_MS = 180_000;
|
|
80
|
+
|
|
81
|
+
/** 中断超过此窗口才告警并投递事件;每段中断只报告一次,恢复时报告持续时长。 */
|
|
82
|
+
const FEED_OUTAGE_MS = 60_000;
|
|
83
|
+
|
|
84
|
+
export interface BilibiliWorldOptions {
|
|
85
|
+
roomId: number;
|
|
86
|
+
/** 登录 cookie 的 SESSDATA;空串=匿名接入 */
|
|
87
|
+
sessdata?: string;
|
|
88
|
+
/** 热改:触发即时投递的礼物金额下限(元)。 */
|
|
89
|
+
giftFlushYuan?: () => number;
|
|
90
|
+
/** 热改:相同弹幕与常规礼物进入总线前的固定归并窗 */
|
|
91
|
+
coalesceWindowMs?: () => number;
|
|
92
|
+
/** 热改:归并池硬上限 */
|
|
93
|
+
coalesceMaxItems?: () => number;
|
|
94
|
+
audienceOnlineRankOn?: () => number;
|
|
95
|
+
audienceOnlineRankOff?: () => number;
|
|
96
|
+
audienceReleaseHoldSec?: () => number;
|
|
97
|
+
audienceSignalFreshSec?: () => number;
|
|
98
|
+
audienceActiveStaleHoldSec?: () => number;
|
|
99
|
+
audienceEventLineBudget?: () => number;
|
|
100
|
+
audienceEventTokenBudget?: () => number;
|
|
101
|
+
audienceImportantShare?: () => number;
|
|
102
|
+
/** World 私有观众准入账本;不存昵称或正文。 */
|
|
103
|
+
audienceLedgerFile?: string;
|
|
104
|
+
/** 原始帧 jsonl 路径;默认位于 audienceLedgerFile 上两级目录,两者都未提供时关闭采样。 */
|
|
105
|
+
rawSampleFile?: string;
|
|
106
|
+
overlay?: BilibiliOverlayConfig;
|
|
107
|
+
onOverlayConfig?: (config: BilibiliOverlayConfig) => void | Promise<void>;
|
|
108
|
+
agentNoticeMaxChars?: () => number;
|
|
109
|
+
agentNoticeFile?: string;
|
|
110
|
+
overlayAssetDir?: string;
|
|
111
|
+
timezone?: string;
|
|
112
|
+
/** 自定义直播源;默认使用 LiveClient。 */
|
|
113
|
+
source?: (handlers: LiveHandlers) => LiveSource;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface LiveSource {
|
|
117
|
+
start(): Promise<void>;
|
|
118
|
+
stop(): Promise<void>;
|
|
119
|
+
current(): LiveStatus;
|
|
120
|
+
/** 自定义 source 只有显式提供时才声明外部关机检查。 */
|
|
121
|
+
shutdownVerification?(): readonly ShutdownExternalCheck[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface LiveHandlers {
|
|
125
|
+
onCmd(msg: Record<string, unknown>): void;
|
|
126
|
+
onStatus(status: LiveStatus): void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Aggregate {
|
|
130
|
+
enter: number;
|
|
131
|
+
like: number;
|
|
132
|
+
freeGift: number;
|
|
133
|
+
watched: number | null;
|
|
134
|
+
online: number | null;
|
|
135
|
+
popularity: number | null;
|
|
136
|
+
fans: number | null;
|
|
137
|
+
likeTotal: number | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface PendingLiveEvent {
|
|
141
|
+
item: LiveEvent;
|
|
142
|
+
ts: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface LiveCandidateValue {
|
|
146
|
+
item: LiveEvent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type LiveAdmissionCandidate = AudienceAdmissionCandidate<LiveCandidateValue>;
|
|
150
|
+
|
|
151
|
+
interface LiveEventParticipant {
|
|
152
|
+
senderKey: string;
|
|
153
|
+
uname?: string;
|
|
154
|
+
count: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function emptyAggregate(): Aggregate {
|
|
158
|
+
return {
|
|
159
|
+
enter: 0,
|
|
160
|
+
like: 0,
|
|
161
|
+
freeGift: 0,
|
|
162
|
+
watched: null,
|
|
163
|
+
online: null,
|
|
164
|
+
popularity: null,
|
|
165
|
+
fans: null,
|
|
166
|
+
likeTotal: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class BilibiliWorld implements World {
|
|
171
|
+
readonly id = 'bilibili';
|
|
172
|
+
|
|
173
|
+
private host: WorldHost | null = null;
|
|
174
|
+
private client: LiveSource | null = null;
|
|
175
|
+
private readonly opts: BilibiliWorldOptions;
|
|
176
|
+
private readonly timezone: string;
|
|
177
|
+
private overlayConfig: BilibiliOverlayConfig;
|
|
178
|
+
private overlayDesignRevision = 0;
|
|
179
|
+
private overlayEditorMutation: Promise<void> = Promise.resolve();
|
|
180
|
+
private readonly announcement: AgentAnnouncementStore;
|
|
181
|
+
private readonly assets: OverlayAssetStore | null;
|
|
182
|
+
private overlayServer: BilibiliOverlayServer | null = null;
|
|
183
|
+
private overlayError: string | null = null;
|
|
184
|
+
private status: LiveStatus | null = null;
|
|
185
|
+
private shutdownChecks: readonly ShutdownExternalCheck[] = [];
|
|
186
|
+
private lifecycleGeneration = 0;
|
|
187
|
+
private readonly coalescing: CoalescingBuffer<PendingLiveEvent>;
|
|
188
|
+
private readonly admission: AudienceAdmission;
|
|
189
|
+
private eventWrites: Promise<void> = Promise.resolve();
|
|
190
|
+
private pendingEventWrites = 0;
|
|
191
|
+
|
|
192
|
+
/** null 表示关闭原始帧采样。 */
|
|
193
|
+
private readonly rawSampleFile: string | null;
|
|
194
|
+
private rawSampleWrites: Promise<void> = Promise.resolve();
|
|
195
|
+
private rawSampleDirReady = false;
|
|
196
|
+
|
|
197
|
+
private rawSampleErrorReported = false;
|
|
198
|
+
|
|
199
|
+
private readonly seenGiftNames = new Set<string>();
|
|
200
|
+
|
|
201
|
+
private unnamedGiftSamples = 0;
|
|
202
|
+
|
|
203
|
+
private readonly recentGuard = new Map<string, { at: number; guardLevel?: number }>();
|
|
204
|
+
|
|
205
|
+
private lastRoomEdge: { living: boolean; at: number } | null = null;
|
|
206
|
+
/** null 表示尚未取得有效房间状态。 */
|
|
207
|
+
private lastKnownLiving: boolean | null = null;
|
|
208
|
+
/** 中断开始的时间戳;null 表示已连接或未启动。 */
|
|
209
|
+
private feedDownSince: number | null = null;
|
|
210
|
+
private feedOutageTimer: ReturnType<typeof setTimeout> | null = null;
|
|
211
|
+
|
|
212
|
+
private feedOutageReported = false;
|
|
213
|
+
|
|
214
|
+
/** 渲染后清空的聚合读数。 */
|
|
215
|
+
private agg = emptyAggregate();
|
|
216
|
+
/** 延迟渲染事件的入队时间;null 表示尚未入队。 */
|
|
217
|
+
private armedAt: number | null = null;
|
|
218
|
+
|
|
219
|
+
private readonly recent: string[] = [];
|
|
220
|
+
/** 累计事件数,包含已从 recent 移除的条目。 */
|
|
221
|
+
private noteSeq = 0;
|
|
222
|
+
/** 包含未识别命令。 */
|
|
223
|
+
private readonly cmdCounts = new Map<string, number>();
|
|
224
|
+
/** 最近弹幕是否具有观众 uid。 */
|
|
225
|
+
private readonly identified: boolean[] = [];
|
|
226
|
+
|
|
227
|
+
private anonymousLoginReported = false;
|
|
228
|
+
/** 登录 uid;0 表示匿名。 */
|
|
229
|
+
private loginUid = 0;
|
|
230
|
+
|
|
231
|
+
private coalesceFolds = 0;
|
|
232
|
+
private coalesceFoldedItems = 0;
|
|
233
|
+
private coalesceLogAt = 0;
|
|
234
|
+
|
|
235
|
+
private admissionDropped = 0;
|
|
236
|
+
|
|
237
|
+
constructor(opts: BilibiliWorldOptions) {
|
|
238
|
+
this.opts = opts;
|
|
239
|
+
this.coalescing = new CoalescingBuffer((groups) => this.emitCoalesced(groups));
|
|
240
|
+
this.admission = new AudienceAdmission({
|
|
241
|
+
file: opts.audienceLedgerFile,
|
|
242
|
+
roomId: opts.roomId,
|
|
243
|
+
tuning: this.audienceTuning(),
|
|
244
|
+
onPersistError: (error) => this.host?.log.warn('B站观众准入账本落盘失败', { err: error.message }),
|
|
245
|
+
});
|
|
246
|
+
this.timezone = opts.timezone ?? 'Asia/Shanghai';
|
|
247
|
+
|
|
248
|
+
this.rawSampleFile = opts.rawSampleFile
|
|
249
|
+
?? (opts.audienceLedgerFile
|
|
250
|
+
? join(dirname(dirname(opts.audienceLedgerFile)), 'bilibili-raw-samples.jsonl')
|
|
251
|
+
: null);
|
|
252
|
+
this.overlayConfig = opts.overlay
|
|
253
|
+
? normalizeOverlayConfig(opts.overlay)
|
|
254
|
+
: { ...cloneOverlayConfig(), enabled: false };
|
|
255
|
+
this.announcement = new AgentAnnouncementStore(opts.agentNoticeFile);
|
|
256
|
+
this.assets = opts.overlayAssetDir ? new OverlayAssetStore(opts.overlayAssetDir) : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
envPromptVars(): Record<string, string> {
|
|
260
|
+
return {
|
|
261
|
+
'bilibili.agentAnnouncement': this.announcement.current.text,
|
|
262
|
+
'bilibili.agentAnnouncementLimit': String(this.agentNoticeLimit()),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console(): WorldConsoleDecl {
|
|
267
|
+
const s = this.status;
|
|
268
|
+
const phase = s?.phase ?? 'stopped';
|
|
269
|
+
const phaseLabel: Record<string, string> = {
|
|
270
|
+
stopped: '未接入',
|
|
271
|
+
connecting: '连接中',
|
|
272
|
+
connected: '已接入',
|
|
273
|
+
retrying: '重连中',
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
|
|
277
|
+
lamps: [
|
|
278
|
+
{
|
|
279
|
+
label: '接入',
|
|
280
|
+
...(phase === 'connected'
|
|
281
|
+
? { state: 'online' as const }
|
|
282
|
+
: phase === 'stopped'
|
|
283
|
+
? { state: 'offline' as const, hint: s?.lastError ?? '未接入' }
|
|
284
|
+
: this.feedOutageReported && this.feedDownSince !== null
|
|
285
|
+
? {
|
|
286
|
+
state: 'error' as const,
|
|
287
|
+
hint: `弹幕接入中断 ${Math.round((Date.now() - this.feedDownSince) / 1000)} 秒,重连中`
|
|
288
|
+
+ (s?.lastError ? `:${s.lastError}` : ''),
|
|
289
|
+
}
|
|
290
|
+
: { state: 'loading' as const, hint: s?.lastError ?? phaseLabel[phase] ?? phase }),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
label: '直播间',
|
|
294
|
+
...(s?.living
|
|
295
|
+
? { state: 'online' as const, hint: `${s.realRoomId ?? s.roomId} · 直播中` }
|
|
296
|
+
: { state: 'offline' as const, hint: s?.realRoomId ? `${s.realRoomId} · 未开播` : '未知' }),
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
label: '身份',
|
|
300
|
+
...(this.desensitized()
|
|
301
|
+
? { state: 'offline' as const, hint: '匿名(认不出人)' }
|
|
302
|
+
: { state: 'online' as const, hint: this.identityLabel() }),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
label: 'Overlay',
|
|
306
|
+
...(this.overlayError
|
|
307
|
+
? { state: 'error' as const, hint: this.overlayError }
|
|
308
|
+
: this.overlayServer?.running
|
|
309
|
+
? { state: 'online' as const, hint: this.overlayServer.overlayUrl }
|
|
310
|
+
: { state: 'offline' as const, hint: this.overlayConfig.enabled ? '未启动' : '未启用' }),
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
badges: [
|
|
314
|
+
{
|
|
315
|
+
label: '接入',
|
|
316
|
+
value: phaseLabel[phase] ?? phase,
|
|
317
|
+
tone: phase === 'connected' ? 'on' : phase === 'stopped' ? 'off' : 'plain',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
label: '直播间',
|
|
321
|
+
value: s?.realRoomId ? `${s.realRoomId}${s.living ? ' · 直播中' : ' · 未开播'}` : '—',
|
|
322
|
+
tone: s?.living ? 'on' : 'plain',
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
label: '身份',
|
|
326
|
+
value: this.identityLabel(),
|
|
327
|
+
tone: this.desensitized() ? 'off' : 'on',
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
label: 'Overlay',
|
|
331
|
+
value: this.overlayServer?.running
|
|
332
|
+
? `${this.overlayServer.subscriberCount} 个订阅`
|
|
333
|
+
: this.overlayConfig.enabled ? '未启动' : '未启用',
|
|
334
|
+
tone: this.overlayServer?.running ? 'on' : 'plain',
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
panels: [...BILIBILI_PANEL_DECLS],
|
|
338
|
+
invoke: async (panel, method, args) => {
|
|
339
|
+
if (panel !== PANEL_LOG) throw new Error(`未知面板: ${panel}`);
|
|
340
|
+
if (method === 'state') return this.logState();
|
|
341
|
+
throw new Error(`未知方法: ${method}`);
|
|
342
|
+
},
|
|
343
|
+
...(this.overlayServer?.running
|
|
344
|
+
? {
|
|
345
|
+
links: [
|
|
346
|
+
{ label: '打开 Overlay 编辑器', href: this.overlayServer.editorUrl, inheritTheme: true },
|
|
347
|
+
{ label: '打开 OBS Overlay', href: this.overlayServer.overlayUrl },
|
|
348
|
+
],
|
|
349
|
+
}
|
|
350
|
+
: {}),
|
|
351
|
+
config: [BILIBILI_CONFIG_GROUP],
|
|
352
|
+
promptDocs: [
|
|
353
|
+
{
|
|
354
|
+
key: 'worlds.bilibili.envPrompt',
|
|
355
|
+
title: 'B 站直播间 · 环境提示词',
|
|
356
|
+
description: '直播间这个场所的常驻事实。',
|
|
357
|
+
path: ENV_PROMPT_FILE,
|
|
358
|
+
role: 'envPrompt',
|
|
359
|
+
vars: [
|
|
360
|
+
{
|
|
361
|
+
name: 'bilibili.agentAnnouncement',
|
|
362
|
+
description: 'Agent 公告栏当前保存的纯文本。',
|
|
363
|
+
multiline: true,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'bilibili.agentAnnouncementLimit',
|
|
367
|
+
description: 'Agent 公告工具当前允许的最大字数。',
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async start(host: WorldHost): Promise<void> {
|
|
376
|
+
const generation = ++this.lifecycleGeneration;
|
|
377
|
+
this.host = host;
|
|
378
|
+
this.armedAt = null;
|
|
379
|
+
this.agg = emptyAggregate();
|
|
380
|
+
this.coalescing.reset();
|
|
381
|
+
this.eventWrites = Promise.resolve();
|
|
382
|
+
this.pendingEventWrites = 0;
|
|
383
|
+
this.rawSampleWrites = Promise.resolve();
|
|
384
|
+
this.rawSampleErrorReported = false;
|
|
385
|
+
this.seenGiftNames.clear();
|
|
386
|
+
this.unnamedGiftSamples = 0;
|
|
387
|
+
this.recentGuard.clear();
|
|
388
|
+
this.lastRoomEdge = null;
|
|
389
|
+
this.lastKnownLiving = null;
|
|
390
|
+
this.clearFeedOutage();
|
|
391
|
+
this.feedOutageReported = false;
|
|
392
|
+
this.shutdownChecks = [];
|
|
393
|
+
this.anonymousLoginReported = false;
|
|
394
|
+
this.loginUid = 0;
|
|
395
|
+
this.coalesceFolds = 0;
|
|
396
|
+
this.coalesceFoldedItems = 0;
|
|
397
|
+
this.coalesceLogAt = Date.now();
|
|
398
|
+
this.admissionDropped = 0;
|
|
399
|
+
await this.startOverlay(host);
|
|
400
|
+
if (!this.opts.roomId && !this.opts.source) {
|
|
401
|
+
host.log.warn('B 站直播间未配置房间号(worlds.bilibili.roomId),不接入');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (!host.pushCandidate) {
|
|
405
|
+
this.host = null;
|
|
406
|
+
throw new Error('B 站直播间需要宿主提供候选投递能力');
|
|
407
|
+
}
|
|
408
|
+
if (!this.opts.sessdata && !this.opts.source) {
|
|
409
|
+
host.log.warn('未配置 worlds.bilibili.sessdata,将以匿名接入——观众 uid 会被服务端抹成 0');
|
|
410
|
+
}
|
|
411
|
+
const handlers: LiveHandlers = {
|
|
412
|
+
onCmd: (msg) => {
|
|
413
|
+
if (generation === this.lifecycleGeneration) this.onCmd(msg);
|
|
414
|
+
},
|
|
415
|
+
onStatus: (s) => {
|
|
416
|
+
if (generation === this.lifecycleGeneration) this.onLiveStatus(s);
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
this.client = this.opts.source
|
|
420
|
+
? this.opts.source(handlers)
|
|
421
|
+
: new LiveClient({
|
|
422
|
+
roomId: this.opts.roomId,
|
|
423
|
+
sessdata: this.opts.sessdata ?? '',
|
|
424
|
+
log: host.log,
|
|
425
|
+
onCmd: handlers.onCmd,
|
|
426
|
+
onStatus: handlers.onStatus,
|
|
427
|
+
});
|
|
428
|
+
this.onLiveStatus(this.client.current());
|
|
429
|
+
this.shutdownChecks = [this.unknownSourceShutdownCheck('直播源尚未执行关机核验')];
|
|
430
|
+
await this.client.start();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async stop(): Promise<void> {
|
|
434
|
+
++this.lifecycleGeneration;
|
|
435
|
+
this.coalescing.flush('stop');
|
|
436
|
+
|
|
437
|
+
this.flushCoalesceLog(true);
|
|
438
|
+
await this.eventWrites;
|
|
439
|
+
|
|
440
|
+
if (this.rawSampleFile) await this.rawSampleWrites;
|
|
441
|
+
const client = this.client;
|
|
442
|
+
const overlay = this.overlayServer;
|
|
443
|
+
this.client = null;
|
|
444
|
+
this.overlayServer = null;
|
|
445
|
+
this.armedAt = null;
|
|
446
|
+
this.clearFeedOutage();
|
|
447
|
+
this.status = null;
|
|
448
|
+
this.host = null;
|
|
449
|
+
|
|
450
|
+
const [clientStop, overlayStop, admissionStop] = await Promise.all([
|
|
451
|
+
client
|
|
452
|
+
? Promise.resolve().then(() => client.stop()).then(
|
|
453
|
+
() => ({ ok: true as const }),
|
|
454
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
455
|
+
)
|
|
456
|
+
: Promise.resolve({ ok: true as const }),
|
|
457
|
+
overlay
|
|
458
|
+
? Promise.resolve().then(() => overlay.stop()).then(
|
|
459
|
+
() => ({ ok: true as const }),
|
|
460
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
461
|
+
)
|
|
462
|
+
: Promise.resolve({ ok: true as const }),
|
|
463
|
+
Promise.resolve().then(() => this.admission.stop()).then(
|
|
464
|
+
() => ({ ok: true as const }),
|
|
465
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
466
|
+
),
|
|
467
|
+
]);
|
|
468
|
+
|
|
469
|
+
const failures: Array<{ part: string; error: unknown }> = [];
|
|
470
|
+
if (client) {
|
|
471
|
+
if (!clientStop.ok) {
|
|
472
|
+
failures.push({ part: '直播源', error: clientStop.error });
|
|
473
|
+
this.shutdownChecks = [this.unknownSourceShutdownCheck(
|
|
474
|
+
`直播源停止失败:${clientStop.error instanceof Error ? clientStop.error.message : String(clientStop.error)}`,
|
|
475
|
+
)];
|
|
476
|
+
} else {
|
|
477
|
+
try {
|
|
478
|
+
this.shutdownChecks = this.sourceShutdownChecks(client);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
failures.push({ part: '关机核验', error });
|
|
481
|
+
this.shutdownChecks = [this.unknownSourceShutdownCheck(
|
|
482
|
+
`读取直播源关机核验失败:${error instanceof Error ? error.message : String(error)}`,
|
|
483
|
+
)];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!overlayStop.ok) failures.push({ part: 'Overlay', error: overlayStop.error });
|
|
488
|
+
if (!admissionStop.ok) failures.push({ part: '观众准入账本', error: admissionStop.error });
|
|
489
|
+
if (failures.length > 0) {
|
|
490
|
+
const details = failures.map(({ part, error }) => (
|
|
491
|
+
`${part}:${error instanceof Error ? error.message : String(error)}`
|
|
492
|
+
));
|
|
493
|
+
throw new AggregateError(failures.map((failure) => failure.error), `B 站 World 停止不完整:${details.join(';')}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
shutdownVerification(): readonly ShutdownExternalCheck[] {
|
|
498
|
+
return this.shutdownChecks.map((check) => ({ ...check }));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private sourceShutdownChecks(source: LiveSource): readonly ShutdownExternalCheck[] {
|
|
502
|
+
if (source.shutdownVerification) {
|
|
503
|
+
const checks = source.shutdownVerification().map((check) => ({ ...check }));
|
|
504
|
+
return checks.length > 0
|
|
505
|
+
? checks
|
|
506
|
+
: [this.unknownSourceShutdownCheck('关机核验未返回任何检查项')];
|
|
507
|
+
}
|
|
508
|
+
return [this.unknownSourceShutdownCheck('已挂载的 LiveSource 未提供关机状态验证')];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private unknownSourceShutdownCheck(detail: string): ShutdownExternalCheck {
|
|
512
|
+
return {
|
|
513
|
+
key: 'bilibili.live-source',
|
|
514
|
+
label: 'B 站直播源',
|
|
515
|
+
status: 'unknown',
|
|
516
|
+
detail,
|
|
517
|
+
manualAction: '立即打开 B 站主播后台或直播伴侣人工确认直播状态;在确认前不要假定直播已经结束。',
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
tools(): ToolDef[] {
|
|
522
|
+
return [
|
|
523
|
+
{
|
|
524
|
+
name: 'bilibili_set_announcement',
|
|
525
|
+
description:
|
|
526
|
+
'Write plain text to the Bilibili Overlay Agent announcement board. '
|
|
527
|
+
+ 'Empty or whitespace-only text is rejected and the board keeps its current content. '
|
|
528
|
+
+ 'The result reports the active character limit.',
|
|
529
|
+
tags: ['speak'],
|
|
530
|
+
parameters: {
|
|
531
|
+
type: 'object',
|
|
532
|
+
properties: {
|
|
533
|
+
text: {
|
|
534
|
+
type: 'string',
|
|
535
|
+
maxLength: this.agentNoticeLimit(),
|
|
536
|
+
description:
|
|
537
|
+
`Announcement text, at most ${this.agentNoticeLimit()} characters. `
|
|
538
|
+
+ 'Must contain visible characters; empty or whitespace-only text is rejected.',
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
required: ['text'],
|
|
542
|
+
additionalProperties: false,
|
|
543
|
+
},
|
|
544
|
+
handler: async (args) => {
|
|
545
|
+
if (typeof args.text !== 'string') return '[bad input] text must be a string';
|
|
546
|
+
|
|
547
|
+
if (args.text.trim() === '') {
|
|
548
|
+
this.host?.log.warn('bilibili_set_announcement 拒绝空白 text', {
|
|
549
|
+
limit: this.agentNoticeLimit(),
|
|
550
|
+
textLength: args.text.length,
|
|
551
|
+
});
|
|
552
|
+
return (
|
|
553
|
+
'[not executed] 传入的 text 是空串或纯空白,这次调用没有执行,公告板保持原样。'
|
|
554
|
+
+ '要写公告就把正文写进 text 再调一次;这个工具不接受用空串清空公告板。'
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const state = this.setAgentAnnouncement(args.text);
|
|
559
|
+
const count = Array.from(state.text).length;
|
|
560
|
+
return `[announcement updated] ${count}/${this.agentNoticeLimit()} chars: ${state.text}`;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return `[bad input] ${error instanceof Error ? error.message : String(error)}`;
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private onCmd(msg: Record<string, unknown>): void {
|
|
570
|
+
const cmd = String(msg.cmd ?? '').split(':')[0] || '(无 cmd)';
|
|
571
|
+
this.cmdCounts.set(cmd, (this.cmdCounts.get(cmd) ?? 0) + 1);
|
|
572
|
+
this.maybeSampleRaw(cmd, msg);
|
|
573
|
+
const item = normalize(msg, {
|
|
574
|
+
giftFlushYuan: this.giftFlushYuan(),
|
|
575
|
+
warn: (message, extra) => this.host?.log.warn(message, extra),
|
|
576
|
+
});
|
|
577
|
+
const overlayEvent = projectOverlayEvent(msg, item);
|
|
578
|
+
|
|
579
|
+
const guardFamily = item !== null && item.kind === 'event'
|
|
580
|
+
&& (item.type === 'bilibili.guard' || item.type === 'bilibili.guard-renew');
|
|
581
|
+
if (overlayEvent && !guardFamily) this.emitOverlayAudience(overlayEvent);
|
|
582
|
+
const host = this.host;
|
|
583
|
+
if (!host) return;
|
|
584
|
+
if (!item) return;
|
|
585
|
+
|
|
586
|
+
if (cmd === 'LIVE') {
|
|
587
|
+
this.admission.startStream({ roomId: this.status?.realRoomId ?? this.opts.roomId });
|
|
588
|
+
this.lastKnownLiving = true;
|
|
589
|
+
|
|
590
|
+
if (!this.markRoomEdge(true, '弹幕服务器 LIVE 指令')) return;
|
|
591
|
+
} else if (cmd === 'PREPARING') {
|
|
592
|
+
this.admission.endStream();
|
|
593
|
+
this.lastKnownLiving = false;
|
|
594
|
+
if (!this.markRoomEdge(false, '弹幕服务器 PREPARING 指令')) return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (item.kind === 'count') {
|
|
598
|
+
if (item.field === 'freeGift') this.coalescing.breakRun('bilibili.gift');
|
|
599
|
+
this.agg[item.field] += item.by;
|
|
600
|
+
this.armAggregate();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (item.kind === 'gauge') {
|
|
604
|
+
if (item.field === 'online') {
|
|
605
|
+
this.admission.updateTuning(this.audienceTuning());
|
|
606
|
+
this.admission.observeOnlineRank(item.value);
|
|
607
|
+
}
|
|
608
|
+
this.agg[item.field] = item.value;
|
|
609
|
+
this.armAggregate();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (item.type === 'bilibili.guard' || item.type === 'bilibili.guard-renew') {
|
|
614
|
+
if (this.suppressDuplicateGuard(item)) return;
|
|
615
|
+
if (overlayEvent) this.emitOverlayAudience(overlayEvent);
|
|
616
|
+
}
|
|
617
|
+
if (item.type === 'bilibili.danmaku') this.noteIdentity(item.senderKey !== undefined);
|
|
618
|
+
this.observeAudience(item);
|
|
619
|
+
this.note(item.text);
|
|
620
|
+
const pending = { item, ts: nowIso(this.timezone) };
|
|
621
|
+
if (item.coalesce && item.trigger !== 'flush') {
|
|
622
|
+
if (item.coalesce.kind === 'gift' && !item.senderKey) {
|
|
623
|
+
this.coalescing.flush('barrier');
|
|
624
|
+
this.pushLiveEvent([pending], pending);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// 归并 key 包含身份,避免不同观众在同一窗口的相同正文丢失各自姓名。匿名接入没有稳定身份键时按正文归并。
|
|
628
|
+
const identity = `\0${item.senderKey ?? ''}`;
|
|
629
|
+
this.coalescing.add(`${item.type}${identity}\0${item.coalesce.key}`, pending, {
|
|
630
|
+
windowMs: this.coalesceWindowMs(),
|
|
631
|
+
maxItems: this.coalesceMaxItems(),
|
|
632
|
+
...(item.coalesce.kind === 'gift' ? { runScope: 'bilibili.gift' } : {}),
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
this.coalescing.flush('barrier');
|
|
637
|
+
this.pushLiveEvent([pending], pending);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** 名单内命令逐条保存原帧;礼物仅采样新名字或无名帧,分别受集合与计数上限约束。 */
|
|
641
|
+
private maybeSampleRaw(cmd: string, msg: Record<string, unknown>): void {
|
|
642
|
+
if (!this.rawSampleFile) return;
|
|
643
|
+
const listed = RAW_SAMPLE_DEFAULT_CMDS.includes(cmd);
|
|
644
|
+
if (!listed) {
|
|
645
|
+
if (cmd !== 'SEND_GIFT' && cmd !== 'SEND_GIFT_V2') return;
|
|
646
|
+
const raw = msg.data !== null && typeof msg.data === 'object'
|
|
647
|
+
? (msg.data as Record<string, unknown>)
|
|
648
|
+
: {};
|
|
649
|
+
// V2 礼物名须先从 protobuf 中读取。
|
|
650
|
+
const data = giftFrameData(raw);
|
|
651
|
+
const name = typeof data.giftName === 'string' && data.giftName
|
|
652
|
+
? data.giftName
|
|
653
|
+
: typeof data.gift_name === 'string' ? data.gift_name : '';
|
|
654
|
+
if (name && this.seenGiftNames.has(name)) return;
|
|
655
|
+
if (name) {
|
|
656
|
+
if (this.seenGiftNames.size >= SEEN_GIFT_NAMES_CAP) return;
|
|
657
|
+
this.seenGiftNames.add(name);
|
|
658
|
+
} else {
|
|
659
|
+
|
|
660
|
+
if (this.unnamedGiftSamples >= UNNAMED_GIFT_SAMPLE_CAP) return;
|
|
661
|
+
this.unnamedGiftSamples += 1;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const file = this.rawSampleFile;
|
|
665
|
+
const line = `${JSON.stringify({ ts: nowIso(this.timezone), cmd, msg })}\n`;
|
|
666
|
+
this.rawSampleWrites = this.rawSampleWrites
|
|
667
|
+
.then(async () => {
|
|
668
|
+
if (!this.rawSampleDirReady) {
|
|
669
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
670
|
+
this.rawSampleDirReady = true;
|
|
671
|
+
}
|
|
672
|
+
await appendFile(file, line, 'utf8');
|
|
673
|
+
})
|
|
674
|
+
.catch((error) => {
|
|
675
|
+
if (this.rawSampleErrorReported) return;
|
|
676
|
+
this.rawSampleErrorReported = true;
|
|
677
|
+
this.host?.log.warn('B站原始帧采样落盘失败', {
|
|
678
|
+
file,
|
|
679
|
+
err: error instanceof Error ? error.message : String(error),
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private suppressDuplicateGuard(item: LiveEvent): boolean {
|
|
685
|
+
if (!item.senderKey) return false;
|
|
686
|
+
const now = Date.now();
|
|
687
|
+
for (const [key, entry] of this.recentGuard) {
|
|
688
|
+
if (now - entry.at > GUARD_DEDUP_WINDOW_MS) this.recentGuard.delete(key);
|
|
689
|
+
}
|
|
690
|
+
const level = typeof item.meta?.guardLevel === 'number' ? item.meta.guardLevel : undefined;
|
|
691
|
+
if (item.type === 'bilibili.guard') {
|
|
692
|
+
this.recentGuard.set(item.senderKey, { at: now, guardLevel: level });
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
const recent = this.recentGuard.get(item.senderKey);
|
|
696
|
+
const duplicate = recent !== undefined
|
|
697
|
+
&& (recent.guardLevel === undefined || level === undefined || recent.guardLevel === level);
|
|
698
|
+
if (duplicate) {
|
|
699
|
+
this.host?.log.info('已过滤重复的大航海 TOAST', {
|
|
700
|
+
senderKey: item.senderKey,
|
|
701
|
+
text: item.text,
|
|
702
|
+
windowMs: GUARD_DEDUP_WINDOW_MS,
|
|
703
|
+
});
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
this.recentGuard.set(item.senderKey, { at: now, guardLevel: level });
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private async startOverlay(host: WorldHost): Promise<void> {
|
|
711
|
+
this.overlayError = null;
|
|
712
|
+
if (!this.overlayConfig.enabled) return;
|
|
713
|
+
if (!this.assets) {
|
|
714
|
+
this.overlayError = '未配置 Overlay 素材目录';
|
|
715
|
+
host.log.warn(this.overlayError);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const server = new BilibiliOverlayServer({
|
|
719
|
+
preferredPort: this.overlayConfig.port,
|
|
720
|
+
assets: this.assets,
|
|
721
|
+
snapshot: () => this.overlaySnapshot(),
|
|
722
|
+
editor: {
|
|
723
|
+
state: () => this.overlayState(),
|
|
724
|
+
saveDesign: (value, baseRevision) => this.runOverlayEditorMutation(
|
|
725
|
+
() => this.saveOverlayDesign(value, baseRevision),
|
|
726
|
+
),
|
|
727
|
+
importAsset: (value) => this.runOverlayEditorMutation(() => this.importOverlayAsset(value)),
|
|
728
|
+
deleteAsset: (value) => this.runOverlayEditorMutation(() => this.deleteOverlayAsset(value)),
|
|
729
|
+
setAgentAnnouncement: (value, expectedRevision) => this.runOverlayEditorMutation(
|
|
730
|
+
() => this.setEditorAnnouncement(value, expectedRevision),
|
|
731
|
+
),
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
try {
|
|
735
|
+
await server.start(host.log);
|
|
736
|
+
this.overlayServer = server;
|
|
737
|
+
} catch (error) {
|
|
738
|
+
this.overlayError = error instanceof Error ? error.message : String(error);
|
|
739
|
+
host.log.warn('B站 Overlay 启动失败', { err: this.overlayError });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private logState(): Record<string, unknown> {
|
|
744
|
+
return {
|
|
745
|
+
status: this.status,
|
|
746
|
+
desensitized: this.desensitized(),
|
|
747
|
+
aggregate: this.agg,
|
|
748
|
+
recent: [...this.recent].reverse(),
|
|
749
|
+
total: this.noteSeq,
|
|
750
|
+
counts: [...this.cmdCounts.entries()].sort((a, b) => b[1] - a[1]),
|
|
751
|
+
coalescing: this.coalescing.state(),
|
|
752
|
+
audienceAdmission: this.admission.snapshot(),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private overlaySnapshot(): Record<string, unknown> {
|
|
757
|
+
return {
|
|
758
|
+
design: structuredClone(this.overlayConfig.design),
|
|
759
|
+
designRevision: this.overlayDesignRevision,
|
|
760
|
+
builtinStyles: structuredClone(BUILTIN_OVERLAY_STYLES),
|
|
761
|
+
agentAnnouncement: this.announcement.current,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private overlayState(): Record<string, unknown> {
|
|
766
|
+
return {
|
|
767
|
+
...this.overlaySnapshot(),
|
|
768
|
+
url: this.overlayServer?.running ? this.overlayServer.overlayUrl : null,
|
|
769
|
+
streamUp: this.overlayServer?.running ?? false,
|
|
770
|
+
error: this.overlayError,
|
|
771
|
+
maxAnnouncementChars: this.agentNoticeLimit(),
|
|
772
|
+
assets: this.assets?.list(this.overlayServer?.running ? this.overlayServer.baseUrl : null) ?? [],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private async saveOverlayDesign(value: unknown, baseRevision: unknown): Promise<Record<string, unknown>> {
|
|
777
|
+
if (!Number.isInteger(baseRevision) || Number(baseRevision) !== this.overlayDesignRevision) {
|
|
778
|
+
throw new OverlayEditorConflictError('Overlay 设计已被其他编辑会话更新');
|
|
779
|
+
}
|
|
780
|
+
const design = normalizeOverlayDesign(value);
|
|
781
|
+
for (const style of design.styles) {
|
|
782
|
+
if (style.nineSlice && !this.assets?.path(style.nineSlice.assetId)) {
|
|
783
|
+
throw new Error(`样式「${style.name}」引用的 Nine-slice 素材不存在`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
for (const component of design.components) {
|
|
787
|
+
if (component.kind === 'image' && component.source === 'upload' && !this.assets?.path(component.assetId)) {
|
|
788
|
+
throw new Error(`图片组件「${component.name}」引用的上传素材不存在`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const next = { ...this.overlayConfig, design };
|
|
792
|
+
await this.opts.onOverlayConfig?.(cloneOverlayConfig(next));
|
|
793
|
+
this.overlayConfig = next;
|
|
794
|
+
this.overlayDesignRevision += 1;
|
|
795
|
+
this.overlayServer?.emitState();
|
|
796
|
+
return { ...this.overlayState(), message: 'Overlay 设计已保存并热更新' };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private importOverlayAsset(value: unknown): Record<string, unknown> {
|
|
800
|
+
if (!this.assets) throw new Error('未配置 Overlay 素材目录');
|
|
801
|
+
const base64 = typeof value === 'string' ? value : '';
|
|
802
|
+
const asset = this.assets.import(base64);
|
|
803
|
+
return { asset, assets: this.assets.list(this.overlayServer?.running ? this.overlayServer.baseUrl : null) };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private deleteOverlayAsset(value: unknown): Record<string, unknown> {
|
|
807
|
+
if (!this.assets) throw new Error('未配置 Overlay 素材目录');
|
|
808
|
+
const id = typeof value === 'string' ? value : '';
|
|
809
|
+
const style = this.overlayConfig.design.styles.find((item) => item.nineSlice?.assetId === id);
|
|
810
|
+
if (style) throw new Error(`素材仍被样式「${style.name}」使用`);
|
|
811
|
+
const component = this.overlayConfig.design.components.find(
|
|
812
|
+
(item) => item.kind === 'image' && item.source === 'upload' && item.assetId === id,
|
|
813
|
+
);
|
|
814
|
+
if (component) throw new Error(`素材仍被组件「${component.name}」使用`);
|
|
815
|
+
const deleted = this.assets.delete(id);
|
|
816
|
+
return {
|
|
817
|
+
deleted,
|
|
818
|
+
assets: this.assets.list(this.overlayServer?.running ? this.overlayServer.baseUrl : null),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private setAgentAnnouncement(value: unknown) {
|
|
823
|
+
if (typeof value !== 'string') throw new Error('公告必须是纯文本');
|
|
824
|
+
const state = this.announcement.set(value, this.agentNoticeLimit());
|
|
825
|
+
this.overlayServer?.emitAnnouncement(state);
|
|
826
|
+
return state;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private setEditorAnnouncement(value: unknown, expectedRevision: unknown): Record<string, unknown> {
|
|
830
|
+
if (!Number.isInteger(expectedRevision) || Number(expectedRevision) !== this.announcement.current.revision) {
|
|
831
|
+
throw new OverlayEditorConflictError('Agent 公告已被其他写入者更新');
|
|
832
|
+
}
|
|
833
|
+
return { ...this.setAgentAnnouncement(value) };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private runOverlayEditorMutation<T>(operation: () => Promise<T> | T): Promise<T> {
|
|
837
|
+
const result = this.overlayEditorMutation.then(operation);
|
|
838
|
+
this.overlayEditorMutation = result.then(() => undefined, () => undefined);
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private emitOverlayAudience(event: OverlayAudienceEvent): void {
|
|
843
|
+
const group = matchAudienceGroup(this.overlayConfig.design.groups, event.facts);
|
|
844
|
+
const projected = { ...event, ...(group ? { groupId: group.id } : {}) };
|
|
845
|
+
this.overlayServer?.emitAudience(projected);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private agentNoticeLimit(): number {
|
|
849
|
+
const value = this.opts.agentNoticeMaxChars?.() ?? this.overlayConfig.agentNoticeMaxChars;
|
|
850
|
+
return Number.isFinite(value) ? Math.min(5000, Math.max(1, Math.round(value))) : 200;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** `piggyback` 随下一批事件投递,不触发唤醒或重置合批计时器。 */
|
|
854
|
+
private armAggregate(): void {
|
|
855
|
+
if (!this.host) return;
|
|
856
|
+
const now = Date.now();
|
|
857
|
+
if (this.armedAt !== null && now - this.armedAt < ARM_STALE_MS) return;
|
|
858
|
+
this.armedAt = now;
|
|
859
|
+
this.host.pushDeferred(
|
|
860
|
+
{
|
|
861
|
+
type: 'bilibili.audience',
|
|
862
|
+
tags: ['snapshot'],
|
|
863
|
+
render: () => {
|
|
864
|
+
this.armedAt = null;
|
|
865
|
+
const line = renderAggregate(this.agg);
|
|
866
|
+
this.agg = emptyAggregate();
|
|
867
|
+
if (line) this.note(line);
|
|
868
|
+
return line;
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
{ trigger: 'piggyback' },
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private giftFlushYuan(): number {
|
|
876
|
+
const v = this.opts.giftFlushYuan?.() ?? BILIBILI_DEFAULTS.giftFlushYuan;
|
|
877
|
+
return Number.isFinite(v) && v >= 0 ? v : BILIBILI_DEFAULTS.giftFlushYuan;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private coalesceWindowMs(): number {
|
|
881
|
+
const value = this.opts.coalesceWindowMs?.() ?? BILIBILI_DEFAULTS.coalesceWindowMs;
|
|
882
|
+
return Number.isFinite(value)
|
|
883
|
+
? Math.min(2000, Math.max(0, Math.round(value)))
|
|
884
|
+
: BILIBILI_DEFAULTS.coalesceWindowMs;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private coalesceMaxItems(): number {
|
|
888
|
+
const value = this.opts.coalesceMaxItems?.() ?? BILIBILI_DEFAULTS.coalesceMaxItems;
|
|
889
|
+
return Number.isFinite(value)
|
|
890
|
+
? Math.min(1000, Math.max(1, Math.round(value)))
|
|
891
|
+
: BILIBILI_DEFAULTS.coalesceMaxItems;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private emitCoalesced(groups: readonly CoalescingGroup<PendingLiveEvent>[]): void {
|
|
895
|
+
for (const group of groups) {
|
|
896
|
+
const pending = group.items.length === 1
|
|
897
|
+
? group.items[0]
|
|
898
|
+
: { ts: group.items[0].ts, item: mergeLiveEvents(group.items.map((entry) => entry.item)) };
|
|
899
|
+
if (group.items.length >= 2) {
|
|
900
|
+
this.coalesceFolds += 1;
|
|
901
|
+
this.coalesceFoldedItems += group.items.length;
|
|
902
|
+
}
|
|
903
|
+
this.pushLiveEvent(group.items, pending);
|
|
904
|
+
}
|
|
905
|
+
this.flushCoalesceLog(false);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** force 在停止时输出尚未满统计窗口的累计值。 */
|
|
909
|
+
private flushCoalesceLog(force: boolean): void {
|
|
910
|
+
if (this.coalesceFolds === 0) return;
|
|
911
|
+
const now = Date.now();
|
|
912
|
+
if (this.coalesceLogAt === 0) this.coalesceLogAt = now;
|
|
913
|
+
if (!force && now - this.coalesceLogAt < COALESCE_LOG_WINDOW_MS) return;
|
|
914
|
+
this.host?.log.info('直播间归并折叠', {
|
|
915
|
+
folds: this.coalesceFolds,
|
|
916
|
+
sourceItems: this.coalesceFoldedItems,
|
|
917
|
+
windowMs: now - this.coalesceLogAt,
|
|
918
|
+
});
|
|
919
|
+
this.coalesceFolds = 0;
|
|
920
|
+
this.coalesceFoldedItems = 0;
|
|
921
|
+
this.coalesceLogAt = now;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private pushLiveEvent(sources: readonly PendingLiveEvent[], { item }: PendingLiveEvent): void {
|
|
925
|
+
const host = this.host;
|
|
926
|
+
if (!host) return;
|
|
927
|
+
const write = async (): Promise<void> => {
|
|
928
|
+
await host.pushCandidate!(
|
|
929
|
+
{
|
|
930
|
+
sourceEvents: sources.map(({ item: source, ts }) => ({
|
|
931
|
+
ts,
|
|
932
|
+
type: source.type,
|
|
933
|
+
text: source.text,
|
|
934
|
+
...(source.senderKey ? { senderKey: source.senderKey } : {}),
|
|
935
|
+
...(source.meta ? { meta: source.meta } : {}),
|
|
936
|
+
})),
|
|
937
|
+
gateText: item.text,
|
|
938
|
+
value: { item } satisfies LiveCandidateValue,
|
|
939
|
+
project: this.projectAudienceCandidates,
|
|
940
|
+
},
|
|
941
|
+
{ trigger: item.trigger },
|
|
942
|
+
);
|
|
943
|
+
};
|
|
944
|
+
this.pendingEventWrites += 1;
|
|
945
|
+
const queued = this.pendingEventWrites === 1 ? write() : this.eventWrites.then(write);
|
|
946
|
+
this.eventWrites = queued
|
|
947
|
+
.catch((error) => host.log.warn('直播间事件投递失败', { err: String(error) }))
|
|
948
|
+
.finally(() => {
|
|
949
|
+
this.pendingEventWrites -= 1;
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private readonly projectAudienceCandidates: CandidateProjector = (sourceCandidates) => {
|
|
954
|
+
this.admission.updateTuning(this.audienceTuning());
|
|
955
|
+
const candidates: LiveAdmissionCandidate[] = sourceCandidates.map((source) => {
|
|
956
|
+
const value = source.value as LiveCandidateValue;
|
|
957
|
+
return {
|
|
958
|
+
stableKey: source.sourceEvents.map((event) => event.cursor).join(','),
|
|
959
|
+
text: value.item.text,
|
|
960
|
+
type: value.item.type,
|
|
961
|
+
senderKeys: audienceSenderKeys(value.item),
|
|
962
|
+
critical: criticalAudienceEvent(value.item),
|
|
963
|
+
meta: value,
|
|
964
|
+
};
|
|
965
|
+
});
|
|
966
|
+
const projected = this.admission.project(candidates);
|
|
967
|
+
const dropped = candidates.length - projected.selected.length;
|
|
968
|
+
if (dropped > 0) {
|
|
969
|
+
// 记录未投递给 agent 的筛除事件,供诊断。
|
|
970
|
+
this.admissionDropped += dropped;
|
|
971
|
+
this.host?.log.warn('直播间准入筛除', {
|
|
972
|
+
dropped,
|
|
973
|
+
candidates: candidates.length,
|
|
974
|
+
selected: projected.selected.length,
|
|
975
|
+
limitingActive: projected.metrics.limitingActive,
|
|
976
|
+
totalDropped: this.admissionDropped,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
return projected.selected.map((selection) => {
|
|
980
|
+
const item = selection.candidate.meta!.item;
|
|
981
|
+
const importantParticipants = audienceParticipantDetails(item, selection.importantParticipants);
|
|
982
|
+
return {
|
|
983
|
+
candidateIndexes: [selection.index],
|
|
984
|
+
event: {
|
|
985
|
+
type: item.type,
|
|
986
|
+
text: item.text,
|
|
987
|
+
...(item.senderKey ? { senderKey: item.senderKey } : {}),
|
|
988
|
+
meta: {
|
|
989
|
+
...item.meta,
|
|
990
|
+
audienceAdmission: {
|
|
991
|
+
limitingActive: projected.metrics.limitingActive,
|
|
992
|
+
lane: selection.lane,
|
|
993
|
+
importantParticipants,
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
});
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
private observeAudience(item: LiveEvent): void {
|
|
1002
|
+
if (!item.senderKey) return;
|
|
1003
|
+
const guardLevel = typeof item.meta?.guardLevel === 'number' ? item.meta.guardLevel : 0;
|
|
1004
|
+
this.admission.observe({
|
|
1005
|
+
senderKey: item.senderKey,
|
|
1006
|
+
interaction: item.type === 'bilibili.danmaku',
|
|
1007
|
+
...(item.type === 'bilibili.superchat' && typeof item.meta?.yuan === 'number'
|
|
1008
|
+
? { superchatYuan: item.meta.yuan }
|
|
1009
|
+
: {}),
|
|
1010
|
+
guard: item.type === 'bilibili.guard' || item.type === 'bilibili.guard-renew',
|
|
1011
|
+
guardLevel,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private onLiveStatus(status: LiveStatus): void {
|
|
1016
|
+
this.status = status;
|
|
1017
|
+
this.loginUid = status.selfUid;
|
|
1018
|
+
this.checkAnonymousLogin(status);
|
|
1019
|
+
this.trackFeedPhase(status.phase);
|
|
1020
|
+
const roomId = status.realRoomId ?? status.roomId;
|
|
1021
|
+
// 首个有效房间状态仅建立基线;后续状态变化参与去重。
|
|
1022
|
+
|
|
1023
|
+
if (status.realRoomId !== null) {
|
|
1024
|
+
const prev = this.lastKnownLiving;
|
|
1025
|
+
this.lastKnownLiving = status.living;
|
|
1026
|
+
if (prev !== null && prev !== status.living
|
|
1027
|
+
&& this.markRoomEdge(status.living, `接口 live_status=${status.living ? 1 : 0}`)) {
|
|
1028
|
+
this.announceRoomEdge(status.living);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (status.living) {
|
|
1032
|
+
this.admission.startStream({ roomId, liveStartedAt: status.liveStartedAt });
|
|
1033
|
+
} else if (status.realRoomId !== null) {
|
|
1034
|
+
this.admission.endStream();
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** 记录状态变化并去重;新变化写日志并返回 true。 */
|
|
1039
|
+
private markRoomEdge(living: boolean, via: string): boolean {
|
|
1040
|
+
const now = Date.now();
|
|
1041
|
+
const last = this.lastRoomEdge;
|
|
1042
|
+
this.lastRoomEdge = { living, at: now };
|
|
1043
|
+
if (last && last.living === living && now - last.at <= ROOM_EDGE_DEDUP_MS) {
|
|
1044
|
+
this.host?.log.info('已过滤重复的直播状态通知', {
|
|
1045
|
+
living,
|
|
1046
|
+
via,
|
|
1047
|
+
sinceMs: now - last.at,
|
|
1048
|
+
});
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
const roomId = this.status?.realRoomId ?? this.opts.roomId;
|
|
1052
|
+
if (living) {
|
|
1053
|
+
this.host?.log.warn(`平台侧直播间已开播(${via})`, { roomId });
|
|
1054
|
+
} else {
|
|
1055
|
+
this.host?.log.error(
|
|
1056
|
+
`平台侧直播间未开播(${via})`,
|
|
1057
|
+
{ roomId },
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** 投递轮询发现的状态变化;WS 状态事件由 normalize 生成。 */
|
|
1064
|
+
private announceRoomEdge(living: boolean): void {
|
|
1065
|
+
this.pushNotice(living
|
|
1066
|
+
? {
|
|
1067
|
+
kind: 'event',
|
|
1068
|
+
type: 'bilibili.room',
|
|
1069
|
+
trigger: 'flush',
|
|
1070
|
+
text: '[直播间] 平台确认已开播(接口 live_status=1):直播画面已对观众可见',
|
|
1071
|
+
}
|
|
1072
|
+
: {
|
|
1073
|
+
kind: 'event',
|
|
1074
|
+
type: 'bilibili.room',
|
|
1075
|
+
trigger: 'flush',
|
|
1076
|
+
text: '[直播间] 平台确认已下播(接口 live_status=0):观众已经看不到直播画面;'
|
|
1077
|
+
+ '此后的弹幕来自仍留在房间页的人,不代表直播还在进行',
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** 投递本 World 生成的通知前,先输出归并缓冲中的事件。 */
|
|
1082
|
+
private pushNotice(item: LiveEvent): void {
|
|
1083
|
+
if (!this.host) return;
|
|
1084
|
+
this.note(item.text);
|
|
1085
|
+
const pending: PendingLiveEvent = { item, ts: nowIso(this.timezone) };
|
|
1086
|
+
this.coalescing.flush('barrier');
|
|
1087
|
+
this.pushLiveEvent([pending], pending);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** connecting/retrying 连续计时;connected 报告已记录中断的恢复,stopped 仅清除计时。 */
|
|
1091
|
+
private trackFeedPhase(phase: LivePhase): void {
|
|
1092
|
+
if (phase === 'connecting' || phase === 'retrying') {
|
|
1093
|
+
if (this.feedDownSince !== null) return;
|
|
1094
|
+
this.feedDownSince = Date.now();
|
|
1095
|
+
this.feedOutageTimer = setTimeout(() => this.reportFeedOutage(), FEED_OUTAGE_MS);
|
|
1096
|
+
this.feedOutageTimer.unref?.();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const since = this.feedDownSince;
|
|
1100
|
+
this.clearFeedOutage();
|
|
1101
|
+
if (phase !== 'connected' || !this.feedOutageReported || since === null) return;
|
|
1102
|
+
this.feedOutageReported = false;
|
|
1103
|
+
const outageSec = Math.round((Date.now() - since) / 1000);
|
|
1104
|
+
this.host?.log.warn('弹幕接入已恢复', { outageSec });
|
|
1105
|
+
this.pushNotice({
|
|
1106
|
+
kind: 'event',
|
|
1107
|
+
type: 'bilibili.feed',
|
|
1108
|
+
trigger: 'debounce',
|
|
1109
|
+
text: `[直播间] 弹幕接入已恢复,中断 ${outageSec} 秒`,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private reportFeedOutage(): void {
|
|
1114
|
+
this.feedOutageTimer = null;
|
|
1115
|
+
const since = this.feedDownSince;
|
|
1116
|
+
if (since === null || !this.host) return;
|
|
1117
|
+
const outageSec = Math.round((Date.now() - since) / 1000);
|
|
1118
|
+
this.feedOutageReported = true;
|
|
1119
|
+
this.host.log.warn(`弹幕接入中断已持续 ${outageSec} 秒,仍在重连:这段的弹幕收不到`, {
|
|
1120
|
+
outageSec,
|
|
1121
|
+
phase: this.status?.phase,
|
|
1122
|
+
lastError: this.status?.lastError,
|
|
1123
|
+
});
|
|
1124
|
+
this.pushNotice({
|
|
1125
|
+
kind: 'event',
|
|
1126
|
+
type: 'bilibili.feed',
|
|
1127
|
+
trigger: 'flush',
|
|
1128
|
+
text: `[直播间] 弹幕接入中断 ${outageSec} 秒,重连中,这段的弹幕收不到`,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private clearFeedOutage(): void {
|
|
1133
|
+
if (this.feedOutageTimer) clearTimeout(this.feedOutageTimer);
|
|
1134
|
+
this.feedOutageTimer = null;
|
|
1135
|
+
this.feedDownSince = null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private checkAnonymousLogin(status: LiveStatus): void {
|
|
1139
|
+
if (status.phase !== 'connected') return;
|
|
1140
|
+
if (!this.opts.sessdata) return;
|
|
1141
|
+
if (status.selfUid > 0) {
|
|
1142
|
+
this.anonymousLoginReported = false;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (this.anonymousLoginReported) return;
|
|
1146
|
+
this.anonymousLoginReported = true;
|
|
1147
|
+
this.host?.log.error(
|
|
1148
|
+
'已配置 worlds.bilibili.sessdata,登录接口仍返回匿名状态(selfUid=0)',
|
|
1149
|
+
{ roomId: status.realRoomId ?? status.roomId },
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private audienceTuning(): Partial<AudienceAdmissionTuning> {
|
|
1154
|
+
const on = integerOption(this.opts.audienceOnlineRankOn, BILIBILI_DEFAULTS.audienceOnlineRankOn, 1, 100000);
|
|
1155
|
+
const off = integerOption(this.opts.audienceOnlineRankOff, BILIBILI_DEFAULTS.audienceOnlineRankOff, 0, on - 1);
|
|
1156
|
+
const freshMs = integerOption(
|
|
1157
|
+
this.opts.audienceSignalFreshSec,
|
|
1158
|
+
BILIBILI_DEFAULTS.audienceSignalFreshSec,
|
|
1159
|
+
1,
|
|
1160
|
+
3600,
|
|
1161
|
+
) * 1000;
|
|
1162
|
+
return {
|
|
1163
|
+
onlineRankCrowdedOn: on,
|
|
1164
|
+
onlineRankCrowdedOff: off,
|
|
1165
|
+
onlineRankReleaseMs: integerOption(
|
|
1166
|
+
this.opts.audienceReleaseHoldSec,
|
|
1167
|
+
BILIBILI_DEFAULTS.audienceReleaseHoldSec,
|
|
1168
|
+
1,
|
|
1169
|
+
1800,
|
|
1170
|
+
) * 1000,
|
|
1171
|
+
onlineRankFreshMs: freshMs,
|
|
1172
|
+
onlineRankStaleHoldMs: Math.max(freshMs, integerOption(
|
|
1173
|
+
this.opts.audienceActiveStaleHoldSec,
|
|
1174
|
+
BILIBILI_DEFAULTS.audienceActiveStaleHoldSec,
|
|
1175
|
+
1,
|
|
1176
|
+
3600,
|
|
1177
|
+
) * 1000),
|
|
1178
|
+
lineBudget: integerOption(
|
|
1179
|
+
this.opts.audienceEventLineBudget,
|
|
1180
|
+
BILIBILI_DEFAULTS.audienceEventLineBudget,
|
|
1181
|
+
1,
|
|
1182
|
+
5000,
|
|
1183
|
+
),
|
|
1184
|
+
tokenBudget: integerOption(
|
|
1185
|
+
this.opts.audienceEventTokenBudget,
|
|
1186
|
+
BILIBILI_DEFAULTS.audienceEventTokenBudget,
|
|
1187
|
+
1,
|
|
1188
|
+
100000,
|
|
1189
|
+
),
|
|
1190
|
+
importantBudgetShare: numberOption(
|
|
1191
|
+
this.opts.audienceImportantShare,
|
|
1192
|
+
BILIBILI_DEFAULTS.audienceImportantShare,
|
|
1193
|
+
0.01,
|
|
1194
|
+
1,
|
|
1195
|
+
),
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private note(text: string): void {
|
|
1200
|
+
this.noteSeq++;
|
|
1201
|
+
this.recent.push(text);
|
|
1202
|
+
if (this.recent.length > RECENT_CAP) this.recent.shift();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
private noteIdentity(identified: boolean): void {
|
|
1206
|
+
this.identified.push(identified);
|
|
1207
|
+
if (this.identified.length > ANON_WINDOW) this.identified.shift();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
private desensitized(): boolean {
|
|
1211
|
+
return this.identified.length > 0 && !this.identified.includes(true);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private identityLabel(): string {
|
|
1215
|
+
if (this.identified.length === 0) return '待观察';
|
|
1216
|
+
return this.desensitized() ? '近期弹幕缺少观众 uid' : '可认人';
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function audienceSenderKeys(item: LiveEvent): string[] {
|
|
1221
|
+
const keys = new Set<string>();
|
|
1222
|
+
if (item.senderKey) keys.add(item.senderKey);
|
|
1223
|
+
const participants = item.meta?.participants;
|
|
1224
|
+
if (Array.isArray(participants)) {
|
|
1225
|
+
for (const value of participants) {
|
|
1226
|
+
if (value === null || typeof value !== 'object') continue;
|
|
1227
|
+
const senderKey = (value as Record<string, unknown>).senderKey;
|
|
1228
|
+
if (typeof senderKey === 'string' && senderKey) keys.add(senderKey);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return [...keys];
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function audienceParticipantDetails(
|
|
1235
|
+
item: LiveEvent,
|
|
1236
|
+
important: readonly ImportantAudienceParticipant[],
|
|
1237
|
+
): Array<{ senderKey: string; uname?: string; count: number; reasons: ImportantAudienceParticipant['reasons'] }> {
|
|
1238
|
+
const details = new Map<string, { uname?: string; count: number }>();
|
|
1239
|
+
if (item.senderKey) {
|
|
1240
|
+
const uname = typeof item.meta?.uname === 'string' && item.meta.uname ? item.meta.uname : undefined;
|
|
1241
|
+
details.set(item.senderKey, { ...(uname ? { uname } : {}), count: 1 });
|
|
1242
|
+
}
|
|
1243
|
+
const participants = item.meta?.participants;
|
|
1244
|
+
if (Array.isArray(participants)) {
|
|
1245
|
+
for (const value of participants) {
|
|
1246
|
+
if (value === null || typeof value !== 'object') continue;
|
|
1247
|
+
const raw = value as Record<string, unknown>;
|
|
1248
|
+
if (typeof raw.senderKey !== 'string' || !raw.senderKey) continue;
|
|
1249
|
+
const uname = typeof raw.uname === 'string' && raw.uname ? raw.uname : undefined;
|
|
1250
|
+
const count = typeof raw.count === 'number' && Number.isInteger(raw.count) && raw.count > 0 ? raw.count : 1;
|
|
1251
|
+
details.set(raw.senderKey, { ...(uname ? { uname } : {}), count });
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return important.map((participant) => {
|
|
1255
|
+
const detail = details.get(participant.senderKey);
|
|
1256
|
+
return {
|
|
1257
|
+
senderKey: participant.senderKey,
|
|
1258
|
+
...(detail?.uname ? { uname: detail.uname } : {}),
|
|
1259
|
+
count: detail?.count ?? 1,
|
|
1260
|
+
reasons: participant.reasons,
|
|
1261
|
+
};
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function criticalAudienceEvent(item: LiveEvent): boolean {
|
|
1266
|
+
return item.trigger === 'flush' || item.type === 'bilibili.guard-renew';
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function integerOption(
|
|
1270
|
+
read: (() => number) | undefined,
|
|
1271
|
+
fallback: number,
|
|
1272
|
+
min: number,
|
|
1273
|
+
max: number,
|
|
1274
|
+
): number {
|
|
1275
|
+
const value = read?.() ?? fallback;
|
|
1276
|
+
const candidate = Number.isFinite(value) ? value : fallback;
|
|
1277
|
+
return Math.min(max, Math.max(min, Math.round(candidate)));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function numberOption(
|
|
1281
|
+
read: (() => number) | undefined,
|
|
1282
|
+
fallback: number,
|
|
1283
|
+
min: number,
|
|
1284
|
+
max: number,
|
|
1285
|
+
): number {
|
|
1286
|
+
const value = read?.() ?? fallback;
|
|
1287
|
+
const candidate = Number.isFinite(value) ? value : fallback;
|
|
1288
|
+
return Math.min(max, Math.max(min, candidate));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function renderAggregate(agg: Aggregate): string | null {
|
|
1292
|
+
const events: string[] = [];
|
|
1293
|
+
if (agg.enter > 0) events.push(`${agg.enter} 人进场`);
|
|
1294
|
+
if (agg.like > 0) events.push(`${agg.like} 次点赞`);
|
|
1295
|
+
if (agg.freeGift > 0) events.push(`${agg.freeGift} 个免费礼物`);
|
|
1296
|
+
const gauges: string[] = [];
|
|
1297
|
+
if (agg.online !== null) gauges.push(`高能榜 ${agg.online} 人`);
|
|
1298
|
+
if (agg.watched !== null) gauges.push(`看过 ${agg.watched}`);
|
|
1299
|
+
if (agg.popularity !== null) gauges.push(`人气 ${agg.popularity}`);
|
|
1300
|
+
if (agg.likeTotal !== null) gauges.push(`累计点赞 ${agg.likeTotal}`);
|
|
1301
|
+
if (agg.fans !== null) gauges.push(`粉丝 ${agg.fans}`);
|
|
1302
|
+
if (events.length === 0 && gauges.length === 0) return null;
|
|
1303
|
+
const parts = [events.length ? `刚才 ${events.join('、')}` : '', gauges.join(',')].filter(Boolean);
|
|
1304
|
+
return `[直播间] ${parts.join(';')}`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function mergeLiveEvents(items: readonly LiveEvent[]): LiveEvent {
|
|
1308
|
+
const first = items[0];
|
|
1309
|
+
const spec = first.coalesce!;
|
|
1310
|
+
const participants = mergeParticipants(items);
|
|
1311
|
+
const senderKeys = participants.map((participant) => participant.senderKey);
|
|
1312
|
+
const commonParticipant = participants.length === 1 && participants[0].count === items.length
|
|
1313
|
+
? participants[0]
|
|
1314
|
+
: undefined;
|
|
1315
|
+
if (spec.kind === 'danmaku') {
|
|
1316
|
+
// 归并正文携带昵称;meta 是驱动层私有数据,不渲染给 agent。
|
|
1317
|
+
const names = participants.map((participant) => participant.uname).filter((name) => !!name);
|
|
1318
|
+
const label = names.length > 0 ? `|${names.join('、')}` : '';
|
|
1319
|
+
return {
|
|
1320
|
+
kind: 'event',
|
|
1321
|
+
type: first.type,
|
|
1322
|
+
trigger: first.trigger,
|
|
1323
|
+
text: `[弹幕×${items.length}${label}] ${spec.body}`,
|
|
1324
|
+
...(commonParticipant ? { senderKey: commonParticipant.senderKey } : {}),
|
|
1325
|
+
meta: {
|
|
1326
|
+
body: spec.body,
|
|
1327
|
+
mergedCount: items.length,
|
|
1328
|
+
senderKeys,
|
|
1329
|
+
participants,
|
|
1330
|
+
...(commonParticipant?.uname ? { uname: commonParticipant.uname } : {}),
|
|
1331
|
+
},
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
const giftSpecs = items.map((item) => item.coalesce as typeof spec);
|
|
1335
|
+
// 任一礼物金额未知时,合并正文省略总金额。
|
|
1336
|
+
const yuan = giftSpecs.some((item) => item.yuan === null)
|
|
1337
|
+
? null
|
|
1338
|
+
: roundedMoney(giftSpecs.reduce((sum, item) => sum + (item.yuan ?? 0), 0));
|
|
1339
|
+
const num = giftSpecs.reduce((sum, item) => sum + item.num, 0);
|
|
1340
|
+
const contributor = participants[0]!;
|
|
1341
|
+
const uname = contributor.uname ?? '某位观众';
|
|
1342
|
+
const money = yuan === null ? '' : ` ¥${formatNumber(yuan)}`;
|
|
1343
|
+
return {
|
|
1344
|
+
kind: 'event',
|
|
1345
|
+
type: first.type,
|
|
1346
|
+
trigger: first.trigger,
|
|
1347
|
+
text: `[礼物×${items.length}笔${money}|${uname}] ${spec.gift}×${num}`,
|
|
1348
|
+
senderKey: contributor.senderKey,
|
|
1349
|
+
meta: {
|
|
1350
|
+
gift: spec.gift,
|
|
1351
|
+
num,
|
|
1352
|
+
...(yuan === null ? {} : { yuan }),
|
|
1353
|
+
mergedCount: items.length,
|
|
1354
|
+
senderKeys,
|
|
1355
|
+
participants,
|
|
1356
|
+
contributors: [{ uname, count: items.length }],
|
|
1357
|
+
...(contributor.uname ? { uname: contributor.uname } : {}),
|
|
1358
|
+
},
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function mergeParticipants(items: readonly LiveEvent[]): LiveEventParticipant[] {
|
|
1363
|
+
const participants = new Map<string, LiveEventParticipant>();
|
|
1364
|
+
for (const item of items) {
|
|
1365
|
+
if (!item.senderKey) continue;
|
|
1366
|
+
const current = participants.get(item.senderKey);
|
|
1367
|
+
if (current) {
|
|
1368
|
+
current.count += 1;
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
const uname = typeof item.meta?.uname === 'string' ? item.meta.uname : '';
|
|
1372
|
+
participants.set(item.senderKey, {
|
|
1373
|
+
senderKey: item.senderKey,
|
|
1374
|
+
...(uname ? { uname } : {}),
|
|
1375
|
+
count: 1,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
return [...participants.values()];
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function roundedMoney(value: number): number {
|
|
1382
|
+
return Math.round(value * 1000) / 1000;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function formatNumber(value: number): string {
|
|
1386
|
+
return value.toFixed(3).replace(/\.?0+$/, '');
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
export type { CountField, GaugeField };
|