mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-11 21:06:56 +08:00
feat: 优化弹幕动效, 开始自动操作编写
This commit is contained in:
642
src/client/store/useAutoAction.ts
Normal file
642
src/client/store/useAutoAction.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { ref, reactive, watch, computed, onUnmounted } from 'vue';
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { EventModel, EventDataTypes, GuardLevel } from '@/api/api-models';
|
||||
import { useDanmakuClient } from '@/store/useDanmakuClient';
|
||||
import { useBiliFunction } from './useBiliFunction';
|
||||
import { useAccount } from '@/api/account';
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
// --- 配置类型定义 ---
|
||||
export interface GiftThankConfig {
|
||||
enabled: boolean;
|
||||
delaySeconds: number; // 延迟感谢秒数 (0表示立即)
|
||||
templates: string[]; // 感谢弹幕模板
|
||||
filterMode: 'none' | 'blacklist' | 'whitelist' | 'value' | 'free'; // 过滤模式
|
||||
filterGiftNames: string[]; // 黑/白名单礼物名称
|
||||
minValue: number; // 最低价值 (用于 value 模式)
|
||||
ignoreTianXuan: boolean; // 屏蔽天选时刻礼物
|
||||
thankMode: 'singleGift' | 'singleUserMultiGift' | 'multiUserMultiGift'; // 感谢模式
|
||||
maxUsersPerMsg: number; // 每次感谢最大用户数
|
||||
maxGiftsPerUser: number; // 每用户最大礼物数 (用于 singleUserMultiGift)
|
||||
includeQuantity: boolean; // 是否包含礼物数量
|
||||
userFilterEnabled: boolean; // 是否启用用户过滤
|
||||
requireMedal: boolean; // 要求本房间勋章
|
||||
requireCaptain: boolean; // 要求任意舰长
|
||||
onlyDuringLive: boolean; // 仅直播中开启
|
||||
}
|
||||
|
||||
export interface GuardPmConfig {
|
||||
enabled: boolean;
|
||||
template: string; // 私信模板
|
||||
sendDanmakuConfirm: boolean; // 是否发送弹幕确认
|
||||
danmakuTemplate: string; // 弹幕确认模板
|
||||
preventRepeat: boolean; // 防止重复发送 (需要本地存储)
|
||||
giftCodeMode: boolean; // 礼品码模式
|
||||
giftCodes: { level: GuardLevel; codes: string[]; }[]; // 分等级礼品码
|
||||
onlyDuringLive: boolean; // 仅直播中开启
|
||||
}
|
||||
|
||||
export interface FollowThankConfig {
|
||||
enabled: boolean;
|
||||
delaySeconds: number;
|
||||
templates: string[];
|
||||
maxUsersPerMsg: number;
|
||||
ignoreTianXuan: boolean;
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
export interface EntryWelcomeConfig {
|
||||
enabled: boolean;
|
||||
delaySeconds: number;
|
||||
templates: string[];
|
||||
maxUsersPerMsg: number;
|
||||
ignoreTianXuan: boolean;
|
||||
userFilterEnabled: boolean;
|
||||
requireMedal: boolean;
|
||||
requireCaptain: boolean;
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledDanmakuConfig {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
messages: string[];
|
||||
mode: 'random' | 'sequential';
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
export interface AutoReplyConfig {
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
rules: { keywords: string[]; replies: string[]; blockwords: string[]; }[];
|
||||
userFilterEnabled: boolean;
|
||||
requireMedal: boolean;
|
||||
requireCaptain: boolean;
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
// --- 聚合数据结构 ---
|
||||
interface AggregatedGift {
|
||||
uid: number;
|
||||
name: string; // 用户名
|
||||
gifts: { [giftName: string]: { count: number; price: number; }; }; // 礼物名 -> {数量, 单价}
|
||||
totalPrice: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface AggregatedUser {
|
||||
uid: number;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
export const useAutoAction = defineStore('autoAction', () => {
|
||||
const danmakuClient = useDanmakuClient();
|
||||
const biliFunc = useBiliFunction();
|
||||
const account = useAccount(); // 用于获取房间ID和直播状态
|
||||
|
||||
// --- 状态定义 ---
|
||||
const giftThankConfig = useStorage<GiftThankConfig>(
|
||||
'autoAction.giftThankConfig',
|
||||
{
|
||||
enabled: false,
|
||||
delaySeconds: 5,
|
||||
templates: ['感谢 {{user.name}} 赠送的 {{gift.summary}}!'],
|
||||
filterMode: 'none',
|
||||
filterGiftNames: [],
|
||||
minValue: 0,
|
||||
ignoreTianXuan: true,
|
||||
thankMode: 'singleUserMultiGift',
|
||||
maxUsersPerMsg: 3,
|
||||
maxGiftsPerUser: 3,
|
||||
includeQuantity: true,
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const guardPmConfig = useStorage<GuardPmConfig>(
|
||||
'autoAction.guardPmConfig',
|
||||
{
|
||||
enabled: false,
|
||||
template: '感谢 {{user.name}} 成为 {{guard.levelName}}!欢迎加入!',
|
||||
sendDanmakuConfirm: false,
|
||||
danmakuTemplate: '已私信 {{user.name}} 舰长福利!',
|
||||
preventRepeat: true,
|
||||
giftCodeMode: false,
|
||||
giftCodes: [],
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const followThankConfig = useStorage<FollowThankConfig>(
|
||||
'autoAction.followThankConfig',
|
||||
{
|
||||
enabled: false,
|
||||
delaySeconds: 10,
|
||||
templates: ['感谢 {{user.name}} 的关注!'],
|
||||
maxUsersPerMsg: 5,
|
||||
ignoreTianXuan: true,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const entryWelcomeConfig = useStorage<EntryWelcomeConfig>(
|
||||
'autoAction.entryWelcomeConfig',
|
||||
{
|
||||
enabled: false,
|
||||
delaySeconds: 15,
|
||||
templates: ['欢迎 {{user.name}} 进入直播间!'],
|
||||
maxUsersPerMsg: 5,
|
||||
ignoreTianXuan: true,
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const scheduledDanmakuConfig = useStorage<ScheduledDanmakuConfig>(
|
||||
'autoAction.scheduledDanmakuConfig',
|
||||
{
|
||||
enabled: false,
|
||||
intervalSeconds: 300,
|
||||
messages: ['点点关注不迷路~'],
|
||||
mode: 'random',
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const autoReplyConfig = useStorage<AutoReplyConfig>(
|
||||
'autoAction.autoReplyConfig',
|
||||
{
|
||||
enabled: false,
|
||||
cooldownSeconds: 5,
|
||||
rules: [],
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
// --- 运行时数据 ---
|
||||
const aggregatedGifts = ref<AggregatedGift[]>([]); // 聚合的礼物信息
|
||||
const aggregatedFollows = ref<AggregatedUser[]>([]); // 聚合的关注用户
|
||||
const aggregatedEntries = ref<AggregatedUser[]>([]); // 聚合的入场用户
|
||||
const sentGuardPms = useStorage<Set<number>>('autoAction.sentGuardPms', new Set()); // 已发送私信的舰长UID
|
||||
const giftThankTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const followThankTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const entryWelcomeTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const scheduledDanmakuTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const lastReplyTimestamps = ref<{ [keyword: string]: number; }>({}); // 自动回复冷却计时
|
||||
const currentScheduledIndex = ref(0); // 定时弹幕顺序模式索引
|
||||
const isTianXuanActive = ref(false); // 天选时刻状态
|
||||
|
||||
// --- Helper Functions ---
|
||||
const isLive = computed(() => account.value.streamerInfo?.isStreaming ?? false); // 获取直播状态
|
||||
const roomId = computed(() => account.value.streamerInfo?.roomId); // 获取房间ID
|
||||
|
||||
// 检查是否应处理事件 (直播状态过滤)
|
||||
function shouldProcess(config: { enabled: boolean; onlyDuringLive: boolean; }): boolean {
|
||||
if (!config.enabled) return false;
|
||||
return !config.onlyDuringLive || isLive.value;
|
||||
}
|
||||
|
||||
// 检查用户过滤
|
||||
function checkUserFilter(config: { userFilterEnabled: boolean; requireMedal: boolean; requireCaptain: boolean; }, event: EventModel): boolean {
|
||||
if (!config.userFilterEnabled) return true;
|
||||
if (config.requireMedal && !event.fans_medal_wearing_status) return false;
|
||||
if (config.requireCaptain && event.guard_level === GuardLevel.None) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取随机模板
|
||||
function getRandomTemplate(templates: string[]): string {
|
||||
if (!templates || templates.length === 0) return '';
|
||||
return templates[Math.floor(Math.random() * templates.length)];
|
||||
}
|
||||
|
||||
// Helper to get nested property value
|
||||
function getNestedValue(obj: Record<string, any>, path: string): any {
|
||||
return path.split('.').reduce((o, k) => (o && typeof o === 'object' && k in o) ? o[k] : undefined, obj);
|
||||
}
|
||||
|
||||
// 格式化消息 (支持 {{object.property}} )
|
||||
function formatMessage(template: string, params: Record<string, any>): string {
|
||||
return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (match, path) => {
|
||||
const value = getNestedValue(params, path);
|
||||
return value !== undefined ? String(value) : match;
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否处于天选时刻
|
||||
function checkTianXuanStatus() {
|
||||
if (!roomId.value) return;
|
||||
// 这里可以调用API检查天选时刻状态
|
||||
// 示例实现,实际应该调用B站API
|
||||
biliFunc.checkRoomTianXuanStatus(roomId.value).then(active => {
|
||||
isTianXuanActive.value = active;
|
||||
});
|
||||
}
|
||||
|
||||
// 每5分钟更新一次天选状态
|
||||
const tianXuanTimer = setInterval(checkTianXuanStatus, 5 * 60 * 1000);
|
||||
|
||||
// 清理所有计时器
|
||||
function clearAllTimers() {
|
||||
[giftThankTimer, followThankTimer, entryWelcomeTimer, scheduledDanmakuTimer].forEach(timer => {
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
});
|
||||
clearInterval(tianXuanTimer);
|
||||
}
|
||||
|
||||
// --- 事件处理 ---
|
||||
|
||||
// 处理礼物事件
|
||||
function onGift(event: EventModel) {
|
||||
if (!shouldProcess(giftThankConfig.value) || !roomId.value) return;
|
||||
if (giftThankConfig.value.ignoreTianXuan && isTianXuanActive.value) return;
|
||||
if (!checkUserFilter(giftThankConfig.value, event)) return;
|
||||
|
||||
// 礼物过滤逻辑
|
||||
const giftName = event.uname;
|
||||
const giftPrice = event.price / 1000; // B站价格单位通常是 1/1000 元
|
||||
const giftCount = event.num;
|
||||
|
||||
switch (giftThankConfig.value.filterMode) {
|
||||
case 'blacklist':
|
||||
if (giftThankConfig.value.filterGiftNames.includes(giftName)) return;
|
||||
break;
|
||||
case 'whitelist':
|
||||
if (!giftThankConfig.value.filterGiftNames.includes(giftName)) return;
|
||||
break;
|
||||
case 'value':
|
||||
if (giftPrice < giftThankConfig.value.minValue) return;
|
||||
break;
|
||||
case 'free':
|
||||
if (giftPrice === 0) return; // 免费礼物价格为0
|
||||
break;
|
||||
}
|
||||
|
||||
// 添加到聚合列表
|
||||
let userGift = aggregatedGifts.value.find(g => g.uid === event.uid);
|
||||
if (!userGift) {
|
||||
userGift = { uid: event.uid, name: event.uname, gifts: {}, totalPrice: 0, timestamp: Date.now() };
|
||||
aggregatedGifts.value.push(userGift);
|
||||
}
|
||||
if (!userGift.gifts[giftName]) {
|
||||
userGift.gifts[giftName] = { count: 0, price: giftPrice };
|
||||
}
|
||||
userGift.gifts[giftName].count += giftCount;
|
||||
userGift.totalPrice += giftPrice * giftCount;
|
||||
userGift.timestamp = Date.now(); // 更新时间戳
|
||||
|
||||
// 重置或启动延迟计时器
|
||||
if (giftThankTimer.value) clearTimeout(giftThankTimer.value);
|
||||
if (giftThankConfig.value.delaySeconds > 0) {
|
||||
giftThankTimer.value = setTimeout(sendGiftThankYou, giftThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
sendGiftThankYou(); // 立即发送
|
||||
}
|
||||
}
|
||||
|
||||
// 发送礼物感谢
|
||||
function sendGiftThankYou() {
|
||||
if (!roomId.value || aggregatedGifts.value.length === 0) return;
|
||||
|
||||
const usersToThank = aggregatedGifts.value.slice(0, giftThankConfig.value.maxUsersPerMsg);
|
||||
aggregatedGifts.value = aggregatedGifts.value.slice(giftThankConfig.value.maxUsersPerMsg); // 移除已处理的用户
|
||||
|
||||
// 根据感谢模式构建弹幕内容
|
||||
let messages: string[] = [];
|
||||
const template = getRandomTemplate(giftThankConfig.value.templates);
|
||||
if (!template) return;
|
||||
|
||||
usersToThank.forEach(user => {
|
||||
const topGifts = Object.entries(user.gifts)
|
||||
.sort(([, a], [, b]) => b.price * b.count - a.price * a.count) // 按总价值排序
|
||||
.slice(0, giftThankConfig.value.maxGiftsPerUser);
|
||||
|
||||
const giftStrings = topGifts.map(([name, data]) =>
|
||||
giftThankConfig.value.includeQuantity ? `${name}x${data.count}` : name
|
||||
);
|
||||
|
||||
if (giftStrings.length > 0) {
|
||||
// 准备模板参数
|
||||
const params = {
|
||||
user: { name: user.name },
|
||||
gift: {
|
||||
summary: giftStrings.join(', '),
|
||||
totalPrice: user.totalPrice.toFixed(2)
|
||||
}
|
||||
};
|
||||
messages.push(formatMessage(template, params));
|
||||
}
|
||||
});
|
||||
|
||||
// 发送弹幕
|
||||
messages.forEach(msg => {
|
||||
if (msg) biliFunc.sendLiveDanmaku(roomId.value!, msg);
|
||||
});
|
||||
|
||||
// 如果还有未感谢的礼物,继续设置计时器
|
||||
if (aggregatedGifts.value.length > 0) {
|
||||
if (giftThankTimer.value) clearTimeout(giftThankTimer.value);
|
||||
giftThankTimer.value = setTimeout(sendGiftThankYou, giftThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
giftThankTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理上舰事件 (Guard)
|
||||
function onGuard(event: EventModel) {
|
||||
if (!shouldProcess(guardPmConfig.value) || !roomId.value) return;
|
||||
|
||||
const userId = event.uid;
|
||||
const userName = event.uname;
|
||||
const guardLevel = event.guard_level;
|
||||
|
||||
if (guardLevel === GuardLevel.None) return; // 不是上舰事件
|
||||
|
||||
// 防止重复发送
|
||||
if (guardPmConfig.value.preventRepeat) {
|
||||
if (sentGuardPms.value.has(userId)) {
|
||||
console.log(`用户 ${userName} (${userId}) 已发送过上舰私信,跳过。`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 查找礼品码
|
||||
let giftCode = '';
|
||||
if (guardPmConfig.value.giftCodeMode) {
|
||||
const levelCodes = guardPmConfig.value.giftCodes.find(gc => gc.level === guardLevel)?.codes;
|
||||
if (levelCodes && levelCodes.length > 0) {
|
||||
giftCode = levelCodes.shift() || '';
|
||||
// 更新储存的礼品码
|
||||
saveGuardConfig();
|
||||
} else {
|
||||
// 尝试查找通用码 (level 0)
|
||||
const commonCodes = guardPmConfig.value.giftCodes.find(gc => gc.level === GuardLevel.None)?.codes;
|
||||
if (commonCodes && commonCodes.length > 0) {
|
||||
giftCode = commonCodes.shift() || '';
|
||||
saveGuardConfig();
|
||||
} else {
|
||||
console.warn(`等级 ${guardLevel} 或通用礼品码已用完,无法发送给 ${userName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化私信内容
|
||||
const guardLevelName = { [GuardLevel.Zongdu]: '总督', [GuardLevel.Tidu]: '提督', [GuardLevel.Jianzhang]: '舰长' }[guardLevel] || '舰长';
|
||||
const pmParams = {
|
||||
user: { name: userName },
|
||||
guard: {
|
||||
levelName: guardLevelName,
|
||||
giftCode: giftCode
|
||||
}
|
||||
};
|
||||
const pmContent = formatMessage(guardPmConfig.value.template, pmParams);
|
||||
|
||||
// 发送私信
|
||||
biliFunc.sendPrivateMessage(userId, pmContent).then(success => {
|
||||
if (success) {
|
||||
console.log(`成功发送上舰私信给 ${userName} (${userId})`);
|
||||
if (guardPmConfig.value.preventRepeat) {
|
||||
sentGuardPms.value.add(userId);
|
||||
}
|
||||
// 发送弹幕确认
|
||||
if (guardPmConfig.value.sendDanmakuConfirm && guardPmConfig.value.danmakuTemplate) {
|
||||
const confirmParams = { user: { name: userName } };
|
||||
const confirmMsg = formatMessage(guardPmConfig.value.danmakuTemplate, confirmParams);
|
||||
biliFunc.sendLiveDanmaku(roomId.value!, confirmMsg);
|
||||
}
|
||||
} else {
|
||||
console.error(`发送上舰私信给 ${userName} (${userId}) 失败`);
|
||||
// 失败时归还礼品码
|
||||
if (giftCode && guardPmConfig.value.giftCodeMode) {
|
||||
returnGiftCode(guardLevel, giftCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 归还礼品码到列表
|
||||
function returnGiftCode(level: GuardLevel, code: string) {
|
||||
const levelCodes = guardPmConfig.value.giftCodes.find(gc => gc.level === level);
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.push(code);
|
||||
} else {
|
||||
guardPmConfig.value.giftCodes.push({ level, codes: [code] });
|
||||
}
|
||||
saveGuardConfig();
|
||||
}
|
||||
|
||||
// 保存舰长配置到本地
|
||||
function saveGuardConfig() {
|
||||
// useStorage会自动保存,无需额外操作
|
||||
}
|
||||
|
||||
// 处理关注事件
|
||||
function onFollow(event: EventModel) {
|
||||
if (!shouldProcess(followThankConfig.value) || !roomId.value) return;
|
||||
if (followThankConfig.value.ignoreTianXuan && isTianXuanActive.value) return;
|
||||
|
||||
aggregatedFollows.value.push({ uid: event.uid, name: event.uname, timestamp: Date.now() });
|
||||
|
||||
if (followThankTimer.value) clearTimeout(followThankTimer.value);
|
||||
if (followThankConfig.value.delaySeconds > 0) {
|
||||
followThankTimer.value = setTimeout(sendFollowThankYou, followThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
sendFollowThankYou();
|
||||
}
|
||||
}
|
||||
|
||||
// 发送关注感谢
|
||||
function sendFollowThankYou() {
|
||||
if (!roomId.value || aggregatedFollows.value.length === 0) return;
|
||||
const usersToThank = aggregatedFollows.value.slice(0, followThankConfig.value.maxUsersPerMsg);
|
||||
aggregatedFollows.value = aggregatedFollows.value.slice(followThankConfig.value.maxUsersPerMsg);
|
||||
|
||||
const template = getRandomTemplate(followThankConfig.value.templates);
|
||||
if (!template) return;
|
||||
|
||||
const names = usersToThank.map(u => u.name).join('、');
|
||||
const params = { user: { name: names } };
|
||||
const message = formatMessage(template, params);
|
||||
|
||||
if (message) biliFunc.sendLiveDanmaku(roomId.value!, message);
|
||||
|
||||
if (aggregatedFollows.value.length > 0) {
|
||||
if (followThankTimer.value) clearTimeout(followThankTimer.value);
|
||||
followThankTimer.value = setTimeout(sendFollowThankYou, followThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
followThankTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理入场事件 (Enter)
|
||||
function onEnter(event: EventModel) {
|
||||
if (!shouldProcess(entryWelcomeConfig.value) || !roomId.value) return;
|
||||
if (entryWelcomeConfig.value.ignoreTianXuan && isTianXuanActive.value) return;
|
||||
if (!checkUserFilter(entryWelcomeConfig.value, event)) return;
|
||||
|
||||
aggregatedEntries.value.push({ uid: event.uid, name: event.uname, timestamp: Date.now() });
|
||||
|
||||
if (entryWelcomeTimer.value) clearTimeout(entryWelcomeTimer.value);
|
||||
if (entryWelcomeConfig.value.delaySeconds > 0) {
|
||||
entryWelcomeTimer.value = setTimeout(sendEntryWelcome, entryWelcomeConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
sendEntryWelcome();
|
||||
}
|
||||
}
|
||||
|
||||
// 发送入场欢迎
|
||||
function sendEntryWelcome() {
|
||||
if (!roomId.value || aggregatedEntries.value.length === 0) return;
|
||||
const usersToWelcome = aggregatedEntries.value.slice(0, entryWelcomeConfig.value.maxUsersPerMsg);
|
||||
aggregatedEntries.value = aggregatedEntries.value.slice(entryWelcomeConfig.value.maxUsersPerMsg);
|
||||
|
||||
const template = getRandomTemplate(entryWelcomeConfig.value.templates);
|
||||
if (!template) return;
|
||||
|
||||
const names = usersToWelcome.map(u => u.name).join('、');
|
||||
const params = { user: { name: names } };
|
||||
const message = formatMessage(template, params);
|
||||
|
||||
if (message) biliFunc.sendLiveDanmaku(roomId.value!, message);
|
||||
|
||||
if (aggregatedEntries.value.length > 0) {
|
||||
if (entryWelcomeTimer.value) clearTimeout(entryWelcomeTimer.value);
|
||||
entryWelcomeTimer.value = setTimeout(sendEntryWelcome, entryWelcomeConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
entryWelcomeTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理弹幕事件 (用于自动回复)
|
||||
function onDanmaku(event: EventModel) {
|
||||
if (!shouldProcess(autoReplyConfig.value) || !roomId.value) return;
|
||||
if (!checkUserFilter(autoReplyConfig.value, event)) return;
|
||||
|
||||
const message = event.msg;
|
||||
const userId = event.uid;
|
||||
const now = Date.now();
|
||||
|
||||
for (const rule of autoReplyConfig.value.rules) {
|
||||
const keywordMatch = rule.keywords.some(kw => message.includes(kw));
|
||||
if (!keywordMatch) continue;
|
||||
|
||||
const blockwordMatch = rule.blockwords.some(bw => message.includes(bw));
|
||||
if (blockwordMatch) continue; // 包含屏蔽词,不回复
|
||||
|
||||
// 检查冷却
|
||||
const ruleKey = rule.keywords.join('|');
|
||||
const lastReplyTime = lastReplyTimestamps.value[ruleKey] || 0;
|
||||
if (now - lastReplyTime < autoReplyConfig.value.cooldownSeconds * 1000) {
|
||||
continue; // 仍在冷却中
|
||||
}
|
||||
|
||||
// 选择回复并发送
|
||||
const reply = getRandomTemplate(rule.replies);
|
||||
if (reply) {
|
||||
const params = { user: { name: event.uname } };
|
||||
const formattedReply = formatMessage(reply, params);
|
||||
biliFunc.sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
lastReplyTimestamps.value[ruleKey] = now; // 更新冷却时间
|
||||
break; // 匹配到一个规则就停止
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送定时弹幕
|
||||
function sendScheduledDanmaku() {
|
||||
if (!shouldProcess(scheduledDanmakuConfig.value) || !roomId.value || scheduledDanmakuConfig.value.messages.length === 0) {
|
||||
stopScheduledDanmaku(); // 停止计时器如果条件不满足
|
||||
return;
|
||||
}
|
||||
|
||||
let message = '';
|
||||
if (scheduledDanmakuConfig.value.mode === 'random') {
|
||||
message = getRandomTemplate(scheduledDanmakuConfig.value.messages);
|
||||
} else {
|
||||
message = scheduledDanmakuConfig.value.messages[currentScheduledIndex.value];
|
||||
currentScheduledIndex.value = (currentScheduledIndex.value + 1) % scheduledDanmakuConfig.value.messages.length;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
biliFunc.sendLiveDanmaku(roomId.value!, message);
|
||||
}
|
||||
|
||||
// 设置下一次定时
|
||||
if (scheduledDanmakuTimer.value) clearTimeout(scheduledDanmakuTimer.value);
|
||||
scheduledDanmakuTimer.value = setTimeout(sendScheduledDanmaku, scheduledDanmakuConfig.value.intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
// 启动定时弹幕
|
||||
function startScheduledDanmaku() {
|
||||
if (scheduledDanmakuTimer.value) clearTimeout(scheduledDanmakuTimer.value); // 清除旧的
|
||||
if (shouldProcess(scheduledDanmakuConfig.value) && scheduledDanmakuConfig.value.intervalSeconds > 0) {
|
||||
scheduledDanmakuTimer.value = setTimeout(sendScheduledDanmaku, scheduledDanmakuConfig.value.intervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止定时弹幕
|
||||
function stopScheduledDanmaku() {
|
||||
if (scheduledDanmakuTimer.value) {
|
||||
clearTimeout(scheduledDanmakuTimer.value);
|
||||
scheduledDanmakuTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听配置变化以启动/停止定时弹幕
|
||||
watch(() => [scheduledDanmakuConfig.value.enabled, scheduledDanmakuConfig.value.onlyDuringLive, isLive.value, scheduledDanmakuConfig.value.intervalSeconds], () => {
|
||||
if (scheduledDanmakuConfig.value.enabled && (!scheduledDanmakuConfig.value.onlyDuringLive || isLive.value)) {
|
||||
startScheduledDanmaku();
|
||||
} else {
|
||||
stopScheduledDanmaku();
|
||||
}
|
||||
}, { immediate: true }); // 立即执行一次检查
|
||||
|
||||
// 当组件卸载时清理所有计时器
|
||||
onUnmounted(() => {
|
||||
clearAllTimers();
|
||||
});
|
||||
|
||||
// 初始化,订阅事件
|
||||
function init() {
|
||||
danmakuClient.onEvent('danmaku', (data) => onDanmaku(data as EventModel));
|
||||
danmakuClient.onEvent('gift', (data) => onGift(data as EventModel));
|
||||
danmakuClient.onEvent('guard', (data) => onGuard(data as EventModel));
|
||||
danmakuClient.onEvent('follow', (data) => onFollow(data as EventModel));
|
||||
danmakuClient.onEvent('enter', (data) => onEnter(data as EventModel));
|
||||
|
||||
// 初始检查天选状态
|
||||
checkTianXuanStatus();
|
||||
|
||||
// 启动定时弹幕(如果初始状态满足条件)
|
||||
startScheduledDanmaku();
|
||||
|
||||
console.log('自动操作模块已初始化');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
// --- 配置 ---
|
||||
giftThankConfig,
|
||||
guardPmConfig,
|
||||
followThankConfig,
|
||||
entryWelcomeConfig,
|
||||
scheduledDanmakuConfig,
|
||||
autoReplyConfig,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useAutoAction, import.meta.hot));
|
||||
}
|
||||
|
||||
export { GuardLevel };
|
||||
@@ -6,6 +6,7 @@ import { QueryBiliAPI } from '../data/utils';
|
||||
import { BiliUserProfile } from '../data/models';
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue';
|
||||
import { StorageSerializers } from '@vueuse/core';
|
||||
|
||||
// --- 常量定义 ---
|
||||
// Tauri Store 存储键名
|
||||
@@ -72,7 +73,11 @@ type CookieCloudState = 'unset' | 'valid' | 'invalid' | 'syncing';
|
||||
export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
// --- 依赖和持久化存储实例 ---
|
||||
// 使用 useTauriStore 获取持久化存储目标
|
||||
const biliCookieStore = useTauriStore().getTarget<BiliCookieStoreData>(BILI_COOKIE_KEY);
|
||||
const biliCookieStore = useStorage<BiliCookieStoreData>(BILI_COOKIE_KEY, {
|
||||
cookie: '',
|
||||
refreshToken: undefined, // 可选,未使用
|
||||
lastRefresh: new Date(0), // 默认值
|
||||
}); // 为保持响应性
|
||||
const cookieCloudStore = useTauriStore().getTarget<CookieCloudConfig>(COOKIE_CLOUD_KEY);
|
||||
const userInfoCacheStore = useTauriStore().getTarget<UserInfoCache>(USER_INFO_CACHE_KEY);
|
||||
|
||||
@@ -328,7 +333,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
|
||||
// 1. 加载持久化数据
|
||||
const [storedCookieData, storedCloudConfig, storedUserInfo] = await Promise.all([
|
||||
biliCookieStore.get(),
|
||||
biliCookieStore.value,
|
||||
cookieCloudStore.get(),
|
||||
userInfoCacheStore.get(),
|
||||
]);
|
||||
@@ -411,7 +416,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
// 如果没有尝试云同步,或者云同步失败,则检查本地 Cookie
|
||||
if (!cloudSyncAttempted || !cloudSyncSuccess) {
|
||||
debug('[BiliCookie] 检查本地存储的 Cookie 有效性...');
|
||||
const storedCookie = (await biliCookieStore.get())?.cookie;
|
||||
const storedCookie = biliCookieStore.value?.cookie;
|
||||
if (storedCookie) {
|
||||
const { valid } = await _checkCookieValidity(storedCookie);
|
||||
// 只有在云同步未成功时才更新状态,避免覆盖云同步设置的状态
|
||||
@@ -450,7 +455,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
lastRefresh: new Date() // 更新刷新时间戳
|
||||
};
|
||||
try {
|
||||
await biliCookieStore.set(dataToStore);
|
||||
biliCookieStore.value = dataToStore; // 使用响应式存储
|
||||
info('[BiliCookie] 新 Bilibili Cookie 已验证并保存');
|
||||
_updateCookieState(true, true); // 更新状态为存在且有效
|
||||
} catch (err) {
|
||||
@@ -473,7 +478,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
* @returns Promise<string | undefined> Cookie 字符串或 undefined
|
||||
*/
|
||||
const getBiliCookie = async (): Promise<string | undefined> => {
|
||||
const data = await biliCookieStore.get();
|
||||
const data = biliCookieStore.value;
|
||||
return data?.cookie;
|
||||
};
|
||||
|
||||
@@ -489,11 +494,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
debug('[BiliCookie] 定时检查已停止');
|
||||
}
|
||||
// 清除 Cookie 存储
|
||||
try {
|
||||
await biliCookieStore.delete();
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 清除 Bilibili Cookie 存储失败: ' + String(err));
|
||||
}
|
||||
biliCookieStore.value = undefined; // 清除持久化存储
|
||||
// 清除用户信息缓存
|
||||
await _clearUserInfoCache();
|
||||
// 重置状态变量
|
||||
@@ -563,6 +564,8 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
uId: computed(() => uId.value), // 只读 ref
|
||||
userInfo, // computed 属性本身就是只读的
|
||||
|
||||
cookie: computed(() => biliCookieStore.value?.cookie), // 只读 ref
|
||||
|
||||
// 方法
|
||||
init,
|
||||
check, // 暴露 check 方法,允许手动触发检查 (例如,应用从后台恢复)
|
||||
|
||||
212
src/client/store/useBiliFunction.ts
Normal file
212
src/client/store/useBiliFunction.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useAccount } from "@/api/account";
|
||||
import { useBiliCookie } from "./useBiliCookie";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; // 引入 Body
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
const biliCookieStore = useBiliCookie();
|
||||
const account = useAccount();
|
||||
const cookie = computed(() => biliCookieStore.cookie);
|
||||
const uid = computed(() => account.value.biliId);
|
||||
|
||||
const csrf = computed(() => {
|
||||
if (!cookie.value) return null;
|
||||
const match = cookie.value.match(/bili_jct=([^;]+)/);
|
||||
return match ? match[1] : null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 发送直播弹幕
|
||||
* @param roomId 直播间 ID
|
||||
* @param message 弹幕内容
|
||||
* @param color 弹幕颜色 (十六进制, 如 FFFFFF)
|
||||
* @param fontsize 字体大小 (默认 25)
|
||||
* @param mode 弹幕模式 (1: 滚动, 4: 底部, 5: 顶部)
|
||||
* @returns Promise<boolean> 是否发送成功 (基于API响应码)
|
||||
*/
|
||||
async function sendLiveDanmaku(roomId: number, message: string, color: string = 'ffffff', fontsize: number = 25, mode: number = 1): Promise<boolean> {
|
||||
if (!csrf.value || !cookie.value) {
|
||||
console.error("发送弹幕失败:缺少 cookie 或 csrf token");
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
console.warn("尝试发送空弹幕,已阻止。");
|
||||
return false;
|
||||
}
|
||||
const url = "https://api.live.bilibili.com/msg/send";
|
||||
const rnd = Math.floor(Date.now() / 1000);
|
||||
const data = {
|
||||
bubble: '0',
|
||||
msg: message,
|
||||
color: parseInt(color, 16).toString(),
|
||||
fontsize: fontsize.toString(),
|
||||
mode: mode.toString(),
|
||||
roomid: roomId.toString(),
|
||||
rnd: rnd.toString(),
|
||||
csrf: csrf.value,
|
||||
csrf_token: csrf.value,
|
||||
};
|
||||
|
||||
try {
|
||||
// 注意: B站网页版发送弹幕是用 application/x-www-form-urlencoded
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://live.bilibili.com/${roomId}`
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("发送弹幕网络失败:", response.status, await response.text());
|
||||
return false;
|
||||
}
|
||||
const json = await response.json();
|
||||
// B站成功码通常是 0
|
||||
if (json.code !== 0) {
|
||||
console.error("发送弹幕API失败:", json.code, json.message || json.msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("发送弹幕成功:", message);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("发送弹幕时发生错误:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁直播间用户 (需要主播或房管权限)
|
||||
* @param roomId 直播间 ID
|
||||
* @param userId 要封禁的用户 UID
|
||||
* @param hours 封禁时长 (小时, 1-720)
|
||||
*/
|
||||
async function banLiveUser(roomId: number, userId: number, hours: number = 1) {
|
||||
// 使用 csrf.value
|
||||
if (!csrf.value || !cookie.value) {
|
||||
console.error("封禁用户失败:缺少 cookie 或 csrf token");
|
||||
return;
|
||||
}
|
||||
// 确保 hours 在 1 到 720 之间
|
||||
const validHours = Math.max(1, Math.min(hours, 720));
|
||||
const url = "https://api.live.bilibili.com/banned_service/v2/Silent/add_user";
|
||||
const data = {
|
||||
room_id: roomId.toString(),
|
||||
block_uid: userId.toString(),
|
||||
hour: validHours.toString(),
|
||||
csrf: csrf.value, // 使用计算属性的值
|
||||
csrf_token: csrf.value, // 使用计算属性的值
|
||||
visit_id: "", // 通常可以为空
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value, // 使用计算属性的值
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://live.bilibili.com/p/html/live-room-setting/#/room-manager/black-list?room_id=${roomId}` // 模拟来源
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("封禁用户失败:", response.status, await response.text());
|
||||
return response.statusText;
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code !== 0) {
|
||||
console.error("封禁用户API失败:", json.code, json.message || json.msg);
|
||||
return json.data;
|
||||
}
|
||||
console.log("封禁用户成功:", json.data);
|
||||
return json.data;
|
||||
} catch (error) {
|
||||
console.error("封禁用户时发生错误:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送私信
|
||||
* @param receiverId 接收者 UID
|
||||
* @param message 私信内容
|
||||
* @returns Promise<boolean> 是否发送成功 (基于API响应码)
|
||||
*/
|
||||
async function sendPrivateMessage(receiverId: number, message: string): Promise<boolean> {
|
||||
if (!csrf.value || !cookie.value || !uid.value) {
|
||||
console.error("发送私信失败:缺少 cookie, csrf token 或 uid");
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
console.warn("尝试发送空私信,已阻止。");
|
||||
return false;
|
||||
}
|
||||
const url = "https://api.vc.bilibili.com/web_im/v1/web_im/send_msg";
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const content = JSON.stringify({ content: message });
|
||||
const dev_id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16).toUpperCase();
|
||||
});
|
||||
const data = {
|
||||
'msg[sender_uid]': uid.value.toString(),
|
||||
'msg[receiver_id]': receiverId.toString(),
|
||||
'msg[receiver_type]': '1',
|
||||
'msg[msg_type]': '1',
|
||||
'msg[msg_status]': '0',
|
||||
'msg[content]': content,
|
||||
'msg[timestamp]': timestamp.toString(),
|
||||
'msg[new_face_version]': '0',
|
||||
'msg[dev_id]': dev_id,
|
||||
'build': '0',
|
||||
'mobi_app': 'web',
|
||||
'csrf': csrf.value,
|
||||
'csrf_token': csrf.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://message.bilibili.com/`,
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("发送私信网络失败:", response.status, await response.text());
|
||||
return false;
|
||||
}
|
||||
// 私信成功码也是 0
|
||||
if (response.data.code !== 0) {
|
||||
console.error("发送私信API失败:", response.data.code, response.data.message);
|
||||
return false;
|
||||
}
|
||||
console.log(`发送私信给 ${receiverId} 成功`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("发送私信时发生错误:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sendLiveDanmaku,
|
||||
banLiveUser,
|
||||
sendPrivateMessage,
|
||||
csrf,
|
||||
uid,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useBiliFunction, import.meta.hot));
|
||||
}
|
||||
@@ -66,16 +66,23 @@ function generateTestDanmaku(): EventModel {
|
||||
const randomTime = Date.now();
|
||||
const randomOuid = `oid_${randomUid}`;
|
||||
|
||||
// 扩展粉丝勋章相关的随机数据
|
||||
const hasMedal = Math.random() > 0.3; // 70% 概率拥有粉丝勋章
|
||||
const isWearingMedal = hasMedal && Math.random() > 0.2; // 佩戴粉丝勋章的概率
|
||||
const medalNames = ['鸽子团', '鲨鱼牌', '椰奶', '饼干', '猫猫头', '南极', '狗妈', '可爱', '团子', '喵'];
|
||||
const randomMedalName = medalNames[Math.floor(Math.random() * medalNames.length)];
|
||||
const randomMedalLevel = isWearingMedal ? Math.floor(Math.random() * 40) + 1 : 0;
|
||||
|
||||
const baseEvent: Partial<EventModel> = {
|
||||
name: randomName,
|
||||
uname: randomName,
|
||||
uface: `https://i0.hdslb.com/bfs/face/member/noface.jpg`, // Placeholder for user avatar
|
||||
uid: randomUid,
|
||||
open_id: randomOuid, // Assuming open_id is same as ouid for test
|
||||
time: randomTime,
|
||||
guard_level: Math.floor(Math.random() * 4) as GuardLevel,
|
||||
fans_medal_level: Math.floor(Math.random() * 41),
|
||||
fans_medal_name: '测试牌',
|
||||
fans_medal_wearing_status: Math.random() > 0.5,
|
||||
fans_medal_level: randomMedalLevel,
|
||||
fans_medal_name: randomMedalName,
|
||||
fans_medal_wearing_status: isWearingMedal,
|
||||
ouid: randomOuid,
|
||||
};
|
||||
|
||||
@@ -243,8 +250,6 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
|
||||
danmakuWindowSetting.value.y = position.y;
|
||||
});
|
||||
|
||||
isWindowOpened.value = true;
|
||||
|
||||
bc = new BroadcastChannel(DANMAKU_WINDOW_BROADCAST_CHANNEL);
|
||||
bc.onmessage = (event: MessageEvent<DanmakuWindowBCData>) => {
|
||||
if (event.data.type === 'window-ready') {
|
||||
@@ -350,7 +355,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
|
||||
// 新增:发送测试弹幕函数
|
||||
function sendTestDanmaku() {
|
||||
if (!isWindowOpened.value || !bc) {
|
||||
console.warn('[danmaku-window] 窗口未打开或 BC 未初始化,无法发送测试弹幕');
|
||||
console.warn('[danmaku-window] 窗口未打开或 BroadcastChannel 未初始化,无法发送测试弹幕');
|
||||
return;
|
||||
}
|
||||
const testData = generateTestDanmaku();
|
||||
|
||||
Reference in New Issue
Block a user