Merge remote-tracking branch 'origin/main'

This commit is contained in:
luojianquan443
2024-04-01 21:42:38 +08:00
10 changed files with 660 additions and 182 deletions

View File

@@ -20,7 +20,7 @@ MoviePilot官方插件市场https://github.com/jxxghp/MoviePilot-Plugins
- 可在插件目录中放置`requirement.txt`文件用于指定插件依赖的第三方库MoviePilot会在插件安装时自动安装依赖库。
### 5. 界面开发
- 插件支持`插件配置``详情展示`两个展示页面,通过配置化的方式组装,使用[Vuetify](https://vuetifyjs.com/)组件库所有该组件库有的组件都可以通过Json配置使用。
- 插件支持`插件配置``详情展示`两个展示页面,通过配置化的方式组装,使用 [Vuetify](https://vuetifyjs.com/) 组件库所有该组件库有的组件都可以通过Json配置使用。
## 常见问题
@@ -48,34 +48,38 @@ MoviePilot官方插件市场https://github.com/jxxghp/MoviePilot-Plugins
PSMoviePilot中的其它事件也是同样方法实现响应
```python
class EventType(Enum):
# 插件重载
# 插件需要重载
PluginReload = "plugin.reload"
# 插件动作
PluginAction = "plugin.action"
# 执行命令
CommandExcute = "command.excute"
# 站点删除
# 站点删除
SiteDeleted = "site.deleted"
# Webhook消息
WebhookMessage = "webhook.message"
# 站点已更新
SiteUpdated = "site.updated"
# 转移完成
TransferComplete = "transfer.complete"
# 添加下载
# 下载已添加
DownloadAdded = "download.added"
# 删除历史记录
HistoryDeleted = "history.deleted"
# 删除下载源文件
DownloadFileDeleted = "downloadfile.deleted"
# 用户外来消息
# 收到用户外来消息
UserMessage = "user.message"
# 通知消息
# 收到Webhook消息
WebhookMessage = "webhook.message"
# 发送消息通知
NoticeMessage = "notice.message"
# 名称识别请求
NameRecognize = "name.recognize"
# 名称识别结果
NameRecognizeResult = "name.recognize.result"
# 站点信息更新
SiteUpdated = "site.updated"
# 订阅已添加
SubscribeAdded = "subscribe.added"
# 订阅已完成
SubscribeComplete = "subscribe.complete"
```
### 2. 如何在插件中实现远程命令响应?

BIN
icons/FeiShu_A.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -185,8 +185,8 @@
},
"CrossSeed": {
"name": "青蛙辅种助手",
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音等。",
"version": "1.8",
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
"version": "2.0",
"icon": "qingwa.png",
"author": "233@qingwa",
"level": 2
@@ -195,7 +195,7 @@
"name": "整理VCB动漫压制组作品",
"description": "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理",
"version": "1.6.6",
"icon": "qingwa.png",
"icon": "vcbmonitor.png",
"author": "pixel@qingwa",
"level": 2
},
@@ -226,7 +226,7 @@
"BrushFlow": {
"name": "站点刷流",
"description": "自动托管刷流,将会提高对应站点的访问频率。",
"version": "2.0",
"version": "2.2",
"icon": "brush.jpg",
"author": "jxxghp",
"level": 2
@@ -450,7 +450,7 @@
"ContractCheck": {
"name": "契约检查",
"description": "定时检查保种契约达成情况。",
"version": "1.0",
"version": "1.1",
"icon": "contract.png",
"author": "DzAvril",
"level": 1
@@ -458,9 +458,17 @@
"DownloaderHelper": {
"name": "下载器助手",
"description": "自动做种、站点标签、自动删种。",
"version": "1.1",
"version": "1.4",
"icon": "DownloaderHelper.png",
"author": "hotlcc",
"level": 2
},
"FeiShuMsg": {
"name": "飞书机器人消息通知",
"description": "支持使用飞书群聊机器人发送消息通知。",
"version": "1.0",
"icon": "FeiShu_A.png",
"author": "InfinityPacer",
"level": 2
}
}

View File

@@ -1,16 +1,14 @@
import base64
import json
import random
import re
import threading
import time
import random
import json
import base64
from datetime import datetime, timedelta
from threading import Event
from typing import Any, List, Dict, Tuple, Optional, Union, Set
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from app import schemas
from app.chain.torrents import TorrentsChain
from app.core.config import settings
@@ -24,6 +22,7 @@ from app.plugins import _PluginBase
from app.schemas import NotificationType, TorrentInfo
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from apscheduler.schedulers.background import BackgroundScheduler
lock = threading.Lock()
@@ -67,6 +66,7 @@ class BrushConfig:
self.brush_sequential = config.get("brush_sequential", False)
self.proxy_download = config.get("proxy_download", True)
self.proxy_delete = config.get("proxy_delete", False)
self.log_more = config.get("log_more", False)
self.active_time_range = config.get("active_time_range")
self.enable_site_config = config.get("enable_site_config", False)
self.brush_tag = "刷流"
@@ -117,8 +117,10 @@ class BrushConfig:
self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False)
except Exception as e:
logger.error(f"解析站点配置失败,请检查配置项错误详情: {e}")
logger.error(f"解析站点配置失败,已停用插件并关闭站点独立配置,请检查配置项错误详情: {e}")
self.group_site_configs = {}
self.enable_site_config = False
self.enabled = False
def get_site_config(self, sitename):
"""
@@ -182,7 +184,7 @@ class BrushFlow(_PluginBase):
# 插件图标
plugin_icon = "brush.jpg"
# 插件版本
plugin_version = "2.0"
plugin_version = "2.2"
# 插件作者
plugin_author = "jxxghp,InfinityPacer"
# 作者主页
@@ -262,6 +264,12 @@ class BrushFlow(_PluginBase):
brush_config.archive_task = False
self.__update_config()
if brush_config.log_more:
if brush_config.enable_site_config:
logger.info(f"已开启站点独立配置,配置信息:{brush_config}")
else:
logger.info(f"没有开启站点独立配置,配置信息:{brush_config}")
# 停止现有任务
self.stop_service()
@@ -282,7 +290,7 @@ class BrushFlow(_PluginBase):
# 如果开启&存在站点时,才需要启用后台任务
self._task_brush_enable = brush_config.enabled and brush_config.brushsites
# brush_config.onlyonce = True
# brush_config.onlyonce = True
# 检查是否启用了一次性任务
if brush_config.onlyonce:
@@ -1030,6 +1038,27 @@ class BrushFlow(_PluginBase):
}
]
},
{
'component': 'VRow',
"content": [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'log_more',
'label': '记录更多日志',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
@@ -1156,7 +1185,8 @@ class BrushFlow(_PluginBase):
"proxy_delete": False,
"freeleech": "free",
"hr": "yes",
"enable_site_config": False
"enable_site_config": False,
"log_more": False
}
def get_page(self) -> List[dict]:
@@ -1658,8 +1688,15 @@ class BrushFlow(_PluginBase):
torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {}
torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
# 判断能否通过保种体积前置条件
size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size)
self.__log_brush_conditions(passed=size_condition_passed, reason=reason)
if not size_condition_passed:
return
# 判断能否通过刷流前置条件
pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(torrents_size=torrents_size)
pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush()
self.__log_brush_conditions(passed=pre_condition_passed, reason=reason)
if not pre_condition_passed:
return
@@ -1695,7 +1732,9 @@ class BrushFlow(_PluginBase):
logger.info(f"刷流任务执行完成")
def __brush_site_torrents(self, siteid, torrent_tasks, statistic_info) -> bool:
"""
针对站点进行刷流
"""
siteinfo = self.siteoper.get(siteid)
if not siteinfo:
logger.warn(f"站点不存在:{siteid}")
@@ -1723,23 +1762,24 @@ class BrushFlow(_PluginBase):
# 过滤种子
for torrent in torrents:
# 判断能否通过刷流前置条件
seeding_size = torrents_size + torrent.size
pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(torrents_size=seeding_size,
include_network_conditions=False)
pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(include_network_conditions=False)
self.__log_brush_conditions(passed=pre_condition_passed, reason=reason, torrent=torrent)
if not pre_condition_passed:
# logger.info(f"种子没有通过刷流前置条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}")
return False
# else:
# logger.info(f"种子已通过刷流前置校验,种子:{torrent.title}|{torrent.description}")
# 判断能否通过保种体积刷流条件
size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size,
brush_torrent_size=torrent.size)
self.__log_brush_conditions(passed=size_condition_passed, reason=reason, torrent=torrent)
if not size_condition_passed:
continue
# 判断能否通过刷流条件
condition_passed, reason = self.__evaluate_conditions_for_brush(torrent=torrent,
torrent_tasks=torrent_tasks)
self.__log_brush_conditions(passed=condition_passed, reason=reason, torrent=torrent)
if not condition_passed:
# logger.info(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}")
continue
# else:
# logger.info(f"种子已通过刷流条件校验,种子:{torrent.title}|{torrent.description}")
# 添加下载任务
hash_string = self.__download(torrent=torrent)
@@ -1790,14 +1830,43 @@ class BrushFlow(_PluginBase):
return True
def __evaluate_pre_conditions_for_brush(self, torrents_size: float,
include_network_conditions: bool = True) -> Tuple[bool, Optional[str]]:
def __evaluate_size_condition_for_brush(self, torrents_size: float,
brush_torrent_size: float = 0.0) -> Tuple[bool, Optional[str]]:
"""
过滤体积不符合条件的种子
"""
total_size = self.__bytes_to_gb(torrents_size + brush_torrent_size) # 预计总做种体积
def generate_message(config):
if brush_torrent_size > 0:
return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB"
f"刷流种子 {self.__bytes_to_gb(brush_torrent_size):.1f} GB"
f"预计做种体积 {total_size:.1f} GB已超过做种体积 {config} GB")
else:
return f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB已超过做种体积 {config} GB暂时停止新增任务"
reasons = [
("disksize",
lambda config: torrents_size + brush_torrent_size > float(config) * 1024 ** 3, generate_message)
]
brush_config = self.__get_brush_config()
for condition, check, message in reasons:
config_value = getattr(brush_config, condition, None)
if config_value and check(config_value):
reason = message(config_value)
return False, reason
return True, None
def __evaluate_pre_conditions_for_brush(self, include_network_conditions: bool = True) \
-> Tuple[bool, Optional[str]]:
"""
前置过滤不符合条件的种子
"""
reasons = [
("maxdlcount", lambda config: self.__get_downloading_count() >= int(config),
lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务"),
("disksize", lambda config: torrents_size > float(config) * 1024 ** 3,
lambda config: f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB"
f"已超过保种体积 {config} GB暂时停止新增任务"),
lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务")
]
if include_network_conditions:
@@ -1819,7 +1888,6 @@ class BrushFlow(_PluginBase):
config_value = getattr(brush_config, condition, None)
if config_value and check(config_value):
reason = message(config_value)
logger.warn(reason)
return False, reason
return True, None
@@ -1894,6 +1962,18 @@ class BrushFlow(_PluginBase):
return True, None
def __log_brush_conditions(self, passed: bool, reason: str, torrent: Any = None):
"""
记录刷流日志
"""
if not passed:
if not torrent:
logger.warn(f"种子没有通过前置刷流条件校验,原因:{reason}")
else:
brush_config = self.__get_brush_config()
if brush_config.log_more:
logger.warn(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}")
# endregion
# region Check
@@ -1944,25 +2024,27 @@ class BrushFlow(_PluginBase):
# 先更新刷流任务的最新状态,上下传,分享率
self.__update_torrent_tasks_state(torrents=check_torrents, torrent_tasks=torrent_tasks)
# 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子
undeleted_hashes = self.__get_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes,
check_torrents) or []
# 排除MoviePilot种子
if check_torrents and brush_config.except_tags:
check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents,
exclude_tag=settings.TORRENT_TAG)
need_delete_hashes = []
need_delete_hashes.extend(undeleted_hashes)
# 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子
undeleted_hashes = self.__get_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes,
check_torrents) or []
# 这里提前把已经被删除的种子进行标记,避免开启动态删除种子统计体积有时差
if undeleted_hashes:
for torrent_hash in undeleted_hashes:
torrent_tasks[torrent_hash]["deleted"] = True
need_delete_hashes = []
# 如果配置了删种阈值,则根据动态删种进行分组处理
if brush_config.proxy_delete and brush_config.delete_size_range:
logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务")
proxy_delete_hashs = self.__delete_torrent_for_proxy(torrents=check_torrents,
torrent_tasks=torrent_tasks) or []
need_delete_hashes.extend(proxy_delete_hashs)
# 否则均认为是没有开启动态删种
# 否则均认为是没有开启动态删种
else:
logger.info("没有开启动态删种,按用户设置删种条件开始检查任务")
not_proxy_delete_hashs = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents,
@@ -2138,9 +2220,9 @@ class BrushFlow(_PluginBase):
def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List:
"""
支持动态删除种子,当设置了动态删种(全局)和删除阈值时,当种体积达到删除阈值时优先按设置规则进行删除若还没有达到阈值则排除HR种子后按加入时间倒序进行删除
删除阈值100种体积 > 100G 时,则开始删除种子,直至降低至 100G
删除阈值50-100种体积 > 100G 时,则开始删除种子,直至降至为 50G
支持动态删除种子,当设置了动态删种(全局)和删除阈值时,当种体积达到删除阈值时优先按设置规则进行删除若还没有达到阈值则排除HR种子后按加入时间倒序进行删除
删除阈值100种体积 > 100G 时,则开始删除种子,直至降低至 100G
删除阈值50-100种体积 > 100G 时,则开始删除种子,直至降至为 50G
"""
brush_config = self.__get_brush_config()
@@ -2150,26 +2232,27 @@ class BrushFlow(_PluginBase):
# 解析删除阈值范围
sizes = [float(size) * 1024 ** 3 for size in brush_config.delete_size_range.split("-")]
min_size = sizes[0] # 至少需要达到的种体积
max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的种体积上限
min_size = sizes[0] # 至少需要达到的种体积
max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的种体积上限
torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents}
# 计算当前总种体积
total_torrent_size = sum(info.get("total_size", 0) for info in torrent_info_map.values())
# 计算当前总种体积
# total_torrent_size = sum(info.get("total_size", 0) for info in torrent_info_map.values())
total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
# 当总体积未超过最大阈值时,不需要执行删除操作
if total_torrent_size < max_size:
logger.info(
f"当前种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB上限 {self.__bytes_to_gb(max_size):.1f} GB下限 {self.__bytes_to_gb(min_size):.1f} GB未触发动态删除")
f"当前种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB上限 {self.__bytes_to_gb(max_size):.1f} GB下限 {self.__bytes_to_gb(min_size):.1f} GB未触发动态删除")
return []
else:
logger.info(
f"当前种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB上限 {self.__bytes_to_gb(max_size):.1f} GB下限 {self.__bytes_to_gb(min_size):.1f} GB触发动态删除")
f"当前种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB上限 {self.__bytes_to_gb(max_size):.1f} GB下限 {self.__bytes_to_gb(min_size):.1f} GB触发动态删除")
need_delete_hashes = []
# 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子动态进行分组,先处理不需要动态的种子,按设置的规则进行删除
# 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除
proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents,
torrent_tasks=torrent_tasks)
logger.info(f"托管种子数 {len(proxy_delete_torrents)},未托管种子数 {len(not_proxy_delete_torrents)}")
@@ -2181,7 +2264,7 @@ class BrushFlow(_PluginBase):
torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in not_proxy_delete_torrents
if self.__get_hash(torrent) in not_proxy_delete_hashes)
# 如果删除非动态种子后仍未达到最小体积要求,则处理动态种子
# 如果删除非托管种子后仍未达到最小体积要求,则处理托管种子
if total_torrent_size > min_size and proxy_delete_torrents:
proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=proxy_delete_torrents,
torrent_tasks=torrent_tasks,
@@ -2191,7 +2274,7 @@ class BrushFlow(_PluginBase):
torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in proxy_delete_torrents if
self.__get_hash(torrent) in proxy_delete_hashes)
# 在完成初始删除步骤后如果总体积仍然超过最小阈值则进一步找到已完成种子并排除HR种子后按加入时间序进行删除
# 在完成初始删除步骤后如果总体积仍然超过最小阈值则进一步找到已完成种子并排除HR种子后按加入时间序进行删除
if total_torrent_size > min_size:
# 重新计算当前的种子列表,排除已删除的种子
remaining_hashes = list(
@@ -2227,7 +2310,7 @@ class BrushFlow(_PluginBase):
reason=reason)
logger.info(f"站点:{site_name}{reason},删除种子:{torrent_title}|{torrent_desc}")
msg = f"已完成 {len(need_delete_hashes)} 个种子删除,当前种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB"
msg = f"已完成 {len(need_delete_hashes)} 个种子删除,当前种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB"
self.post_message(mtype=NotificationType.SiteMessage, title="【刷流任务种子删除】", text=msg)
logger.info(msg)
@@ -2453,6 +2536,7 @@ class BrushFlow(_PluginBase):
"brush_sequential": brush_config.brush_sequential,
"proxy_download": brush_config.proxy_download,
"proxy_delete": brush_config.proxy_delete,
"log_more": brush_config.log_more,
"active_time_range": brush_config.active_time_range,
"enable_site_config": brush_config.enable_site_config,
"site_config": brush_config.site_config
@@ -2604,7 +2688,8 @@ class BrushFlow(_PluginBase):
# 获取种子Hash
torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag)
if not torrent_hash:
logger.error(f"{brush_config.downloader} 获取种子Hash失败")
logger.error(f"{brush_config.downloader} 获取种子Hash失败"
f"{',请尝试开启代理下载种子' if not brush_config.proxy_download else ''}")
return None
return torrent_hash
return None
@@ -2622,7 +2707,7 @@ class BrushFlow(_PluginBase):
else:
logger.error('代理下载种子失败,继续尝试传递种子地址到下载器进行下载')
if torrent_content:
torrent = self.tr.add_torrent(content=torrent.enclosure,
torrent = self.tr.add_torrent(content=torrent_content,
download_dir=download_dir,
cookie=torrent.site_cookie,
labels=["已整理", brush_config.brush_tag])

View File

@@ -39,7 +39,7 @@ class ContractCheck(_PluginBase):
# 插件图标
plugin_icon = "contract.png"
# 插件版本
plugin_version = "1.0"
plugin_version = "1.1"
# 插件作者
plugin_author = "DzAvril"
# 作者主页
@@ -108,11 +108,11 @@ class ContractCheck(_PluginBase):
)
self._site_schema.sort(key=lambda x: x.order)
# 站点数据
self._sites_data = {}
# 立即运行一次
if self._onlyonce:
# 站点数据
self._sites_data = {}
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"保种契约检查服务启动,立即运行一次")

View File

@@ -1,6 +1,7 @@
import hashlib
import os
import re
import time
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
@@ -33,18 +34,33 @@ class CSSiteConfig(object):
站点辅种配置类
"""
def __init__(self, site_name: str, site_url: str, site_passkey: str) -> None:
self.name = site_name
self.url = site_url.removesuffix("/")
self.passkey = site_passkey
def __init__(
self,
name: str = None,
url: str = None,
passkey: str = None,
id: int = None,
cookie: str = None,
ua: str = None,
proxy: bool = None,
query_gap: int = 1,
) -> None:
self.name = name
self.url = url
self.passkey = passkey
self.id = id
self.cookie = cookie
self.ua = ua
self.proxy = proxy
self.query_gap = query_gap
def get_api_url(self):
if self.name == "憨憨":
return f"{self.url}/npapi/pieces-hash"
return f"{self.url}/api/pieces-hash"
return f"{self.url}npapi/pieces-hash"
return f"{self.url}api/pieces-hash"
def get_torrent_url(self, torrent_id: str):
return f"{self.url}/download.php?id={torrent_id}&passkey={self.passkey}"
return f"{self.url}download.php?id={torrent_id}&passkey={self.passkey}"
class TorInfo:
@@ -135,21 +151,21 @@ class CrossSeedHelper(object):
"User-Agent": "CrossSeedHelper",
}
data = {"passkey": site.passkey, "pieces_hash": pieces_hash_set}
remote_torrent_infos = []
try:
response = requests.post(
site.get_api_url(), headers=headers, json=data, timeout=10
)
response.raise_for_status()
rsp_body = response.json()
if isinstance(rsp_body["data"], dict):
for pieces_hash, torrent_id in rsp_body["data"].items():
remote_torrent_infos.append(
TorInfo.remote(site.name, pieces_hash, torrent_id)
)
time.sleep(site.query_gap)
except requests.exceptions.RequestException as e:
return None, f"站点{site.name}请求失败:{e}"
rsp_body = response.json()
remote_torrent_infos = []
if isinstance(rsp_body["data"], dict):
for pieces_hash, torrent_id in rsp_body["data"].items():
remote_torrent_infos.append(
TorInfo.remote(site.name, pieces_hash, torrent_id)
)
return remote_torrent_infos, None
@@ -157,11 +173,11 @@ class CrossSeed(_PluginBase):
# 插件名称
plugin_name = "青蛙辅种助手"
# 插件描述
plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音等。"
plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。"
# 插件图标
plugin_icon = "qingwa.png"
# 插件版本
plugin_version = "1.8"
plugin_version = "2.0"
# 插件作者
plugin_author = "233@qingwa"
# 作者主页
@@ -206,7 +222,6 @@ class CrossSeed(_PluginBase):
# 辅种缓存出错的种子不再重复辅种且无法清除。种子被删除404等情况
_permanent_error_caches = []
_torrentpaths = []
_name_site_map = {}
_site_cs_infos = []
# 辅种计数
total = 0
@@ -229,7 +244,7 @@ class CrossSeed(_PluginBase):
self._downloaders = config.get("downloaders")
self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr
self._torrentpaths = self._torrentpath.split(",")
self._torrentpaths = self._torrentpath.strip().split(",")
self._sites = config.get("sites") or []
self._notify = config.get("notify")
self._nolabels = config.get("nolabels")
@@ -247,18 +262,61 @@ class CrossSeed(_PluginBase):
self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites]
# 拆分出选中的站点
site_names = [site_name for site_id, site_name in all_sites if site_id in self._sites]
# 拆分为映射关系
self._name_site_map = {}
for site in self.siteoper.list_order_by_pri():
self._name_site_map[site.name] = site
# 只给选中的站点构造站点配置
self._site_cs_infos: List[CSSiteConfig] = []
# 整理所有可用内部站点信息
all_site_cs_info_map : dict[str, CSSiteConfig] = dict()
for site in inner_site_list:
if site.is_active:
all_site_cs_info_map[site.name] = CSSiteConfig(
name=site.name,
url=site.url,
id=site.id,
cookie=site.cookie,
ua=site.ua,
proxy=site.proxy,
)
for site in self.__custom_sites():
all_site_cs_info_map[site.get("name")] = CSSiteConfig(
name=site.get("name"),
url=site.get("url"),
id=site.get("id"),
cookie=site.get("cookie"),
ua=site.get("ua"),
proxy=site.get("proxy"),
)
self._sites = [site.id for site in all_site_cs_info_map.values() if site.id in self._sites]
site_names = [site.name for site in all_site_cs_info_map.values() if site.id in self._sites]
# 整理passkey映射关系
site_name_key_map = dict()
site_name_gap_map = dict()
for site_key in self._token.strip().split("\n"):
site_key_arr = re.split("[\s:]+",site_key.strip())
site_name = site_key_arr[0]
db_site = self._name_site_map[site_name]
if site_name in site_names and db_site:
self._site_cs_infos.append(CSSiteConfig(site_name, db_site.url, site_key_arr[1]))
site_name_key_map[site_name] = site_key_arr[1]
if len(site_key_arr) > 2:
if str.isdigit(site_key_arr[2]):
site_name_gap_map[site_name] = int(site_key_arr[2])
else:
logger.warn(
f"站点{site_name}配置的查询请求间隔时间不为整数,不能生效, 请修改 {site_key_arr[2]}"
)
# 只给选中的站点补全站点配置
self._site_cs_infos: List[CSSiteConfig] = []
# 根据配置来补充passkey
for site_name in site_names:
site_key = site_name_key_map.get(site_name)
if not site_key:
logger.warning(f"未找到站点{site_name}的passkey, 请检查passkey配置是否有误站点{site_name}将跳过辅种")
continue
site_cs_info = all_site_cs_info_map.get(site_name)
site_cs_info.passkey = site_key
# 追加设置的请求间隔时间
site_query_gap = site_name_gap_map.get(site_name)
if site_query_gap:
site_cs_info.query_gap = site_query_gap
self._site_cs_infos.append(site_cs_info)
self.__update_config()
@@ -611,7 +669,7 @@ class CrossSeed(_PluginBase):
'type': 'info',
'variant': 'tonal',
'text': '1. 定时任务周期建议每次辅种间隔时间大于1天不填写每天上午2点到7点随机辅种一次 '
'2. 支持辅种站点列表:青蛙【已验证】,AGSVPT麒麟UBits聆音 配置passkey时站点名称需严格和上面选项一致只有选中的站点会辅种passkey可保存多个 '
'2. 支持辅种站点列表:青蛙AGSVPT、红豆饭、麒麟UBits聆音等配置passkey时站点名称需严格和上面选项一致只有选中的站点会辅种passkey可保存多个 '
'3. 请勿与IYUU辅种插件同时添加相同站点可能会有冲突且意义不大'
'4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 '
}
@@ -619,6 +677,29 @@ class CrossSeed(_PluginBase):
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '【进阶设置】如果辅种过程中访问/api/pieces-hash接口偶尔会失败可以设置请求间隔时间。 '
'可以在passkey后增加 :3 来将某个站点的请求间隔调整为3秒3可以改为其他数字只能为整数默认请求间隔为1秒。 '
'示例配置 站点名称:Passkey:3'
}
}
]
}
]
}
]
}
@@ -709,14 +790,14 @@ class CrossSeed(_PluginBase):
if not torrent_path.exists():
if downloader == "qbittorrent":
# FIXME qb从4.4.0开始,种子文件以标题+序号的方式保存,目前只能尝试导出后再解析
# logger.info(f"正在导出种子 {torrent.get('name')}({hash_str})")
logger.warn(f"QB种子文件不存在{torrent_path} 尝试远程导出种子")
try:
torrent_data = torrent.export()
torrent_info, err = TorInfo.from_data(torrent_data)
except Exception as e:
err = str(e)
if not torrent_info:
logger.error(f"尝试导出种子 {hash_str} 出错 {err}")
logger.error(f"尝试远程导出种子 {hash_str} 出错 {err}")
continue
else:
logger.error(f"种子文件不存在:{torrent_path}")
@@ -873,12 +954,12 @@ class CrossSeed(_PluginBase):
# 逐个站点查询可辅种数据
chunk_size = 100
for site_config in self._site_cs_infos:
db_site_info = self._name_site_map[site_config.name]
if not db_site_info:
logger.info(f"未在支持站点中找到{site_config.name}")
remote_tors: List[TorInfo] = []
total_size = len(pieces_hashes)
for i in range(0, len(pieces_hashes), chunk_size):
if self._event.is_set():
logger.info(f"辅种服务停止")
return
# 切片操作
chunk = pieces_hashes[i:i + chunk_size]
# 处理分组
@@ -911,6 +992,9 @@ class CrossSeed(_PluginBase):
logger.info(f"站点{site_config.name}正在做种或已经辅种过的种子数为{local_cnt}")
for tor_info in not_local_tors:
if self._event.is_set():
logger.info(f"辅种服务停止")
return
if not tor_info:
continue
if not tor_info.torrent_id or not tor_info.pieces_hash:
@@ -922,7 +1006,7 @@ class CrossSeed(_PluginBase):
logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...")
continue
# 添加任务
self.__download_torrent(tor=tor_info, site_config=site_config, site_info=db_site_info,
self.__download_torrent(tor=tor_info, site_config=site_config,
downloader=downloader,
save_path=save_paths.get(tor_info.pieces_hash))
@@ -967,7 +1051,6 @@ class CrossSeed(_PluginBase):
self,
tor: TorInfo,
site_config: CSSiteConfig,
site_info: Site,
downloader: str,
save_path: str,
):
@@ -984,9 +1067,9 @@ class CrossSeed(_PluginBase):
# 下载种子文件
_, content, _, _, error_msg = self.torrent.download_torrent(
url=torrent_url,
cookie=site_info.cookie,
ua=site_info.ua or settings.USER_AGENT,
proxy=True if site_info.proxy else False)
cookie=site_config.cookie,
ua=site_config.ua or settings.USER_AGENT,
proxy=True if site_config.proxy else False)
# 兼容种子无法访问的情况
if not content or (isinstance(content, bytes) and "你没有该权限".encode(encoding="utf-8") in content):

View File

@@ -43,7 +43,7 @@
|定时执行周期|插件定时服务的cron表达式仅支持5位的缺省时不注册定时服务。|
|排除种子标签|多个标签通过英文逗号分割,具备配置的任意标签的种子不会进行自动做种、站点标签、自动删种操作。|
|站点标签前缀|站点标签的前缀,缺省时不添加前缀。|
|Tracker映射|站点标签的原理是根据tracker的域名去匹配站点但是有的PT站的tracker域名和站点域名不一致导致匹配不到站点因此需要对这些特殊站点的tracker做映射每行一个映射格式是 `tracker域名:站点域名`tracker域名至少需要配置二级,站点域名只能配置二级。|
|Tracker映射|站点标签的原理是根据tracker的域名去匹配站点但是有的PT站的tracker域名和站点域名不一致导致匹配不到站点因此需要对这些特殊站点的tracker做映射每行一个映射格式是 `tracker域名:站点域名`tracker域名可以是完整域名或者主域名。|
##### 2.1.2、下载器子任务配置项

View File

@@ -32,7 +32,7 @@ class DownloaderHelper(_PluginBase):
# 插件图标
plugin_icon = "DownloaderHelper.png"
# 插件版本
plugin_version = "1.1"
plugin_version = "1.4"
# 插件作者
plugin_author = "hotlcc"
# 作者主页
@@ -77,6 +77,8 @@ class DownloaderHelper(_PluginBase):
__tracker_mappings: Dict[str, str] = {}
# 排除种子标签
__exclude_tags: Set[str] = set()
# 多级根域名,用于在打标时做特殊处理
__multi_level_root_domain: List[str] = ['edu.cn', 'com.cn', 'net.cn', 'org.cn']
def init_plugin(self, config: dict = None):
"""
@@ -175,87 +177,90 @@ class DownloaderHelper(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '插件总开关'
'md': 4,
'xl': 3
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'enable',
'label': '启用插件'
'label': '启用插件',
'hint': '插件总开关'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '执行插件任务后是否发送通知'
'md': 4,
'xl': 3
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'enable_notify',
'label': '发送通知'
'label': '发送通知',
'hint': '执行插件任务后是否发送通知'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '保存插件配置后是否立即触发一次插件任务运行'
'md': 4,
'xl': 3
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'run_once',
'label': '立即运行一次'
'label': '立即运行一次',
'hint': '保存插件配置后是否立即触发一次插件任务运行'
}
}]
}]
}, {
'component': 'VRow',
'content': [{
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '监听下载添加事件。当MoviePilot添加下载任务时会触发执行本插件进行自动做种和添加站点标签。'
'md': 4,
'xl': 3
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'listen_download_event',
'label': '监听下载事件'
'label': '监听下载事件',
'hint': '监听下载添加事件。当MoviePilot添加下载任务时会触发执行本插件进行自动做种和添加站点标签。'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '监听源文件删除事件。当在【历史记录】中删除源文件时,会自动触发运行本插件任务进行自动删种。'
'md': 4,
'xl': 3
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'listen_source_file_event',
'label': '监听源文件事件'
'label': '监听源文件事件',
'hint': '监听源文件删除事件。当在【历史记录】中删除源文件时,会自动触发运行本插件任务进行自动删种。'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '给种子添加站点标签时,是否优先以站点名称作为标签内容(否则将使用域名关键字)?'
'md': 4,
'xl': 3
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'site_name_priority',
'label': '站点名称优先'
'label': '站点名称优先',
'hint': '给种子添加站点标签时,是否优先以站点名称作为标签内容(否则将使用域名关键字)?'
}
}]
}]
@@ -265,44 +270,44 @@ class DownloaderHelper(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '设置插件任务执行周期。支持5位cron表达式应避免任务执行过于频繁例如0/30 * * * *。缺省时不执行定时任务,但不影响监听任务的执行。'
'md': 4
},
'content': [{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '定时执行周期',
'placeholder': '0/30 * * * *'
'placeholder': '0/30 * * * *',
'hint': '设置插件任务执行周期。支持5位cron表达式应避免任务执行过于频繁例如0/30 * * * *。缺省时不执行定时任务,但不影响监听任务的执行。'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '下载器中的种子有这些标签时不进行任何操作,多个标签使用英文“,”分割'
'md': 4
},
'content': [{
'component': 'VTextField',
'props': {
'model': 'exclude_tags',
'label': '排除种子标签'
'label': '排除种子标签',
'hint': '下载器中的种子有这些标签时不进行任何操作,多个标签使用英文“,”分割'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12,
'title': '给种子添加站点标签时的标签前缀,默认值为“站点/”'
'md': 4
},
'content': [{
'component': 'VTextField',
'props': {
'model': 'tag_prefix',
'label': '站点标签前缀',
'placeholder': '站点/'
'placeholder': '站点/',
'hint': '给种子添加站点标签时的标签前缀,默认值为“站点/”'
}
}]
}]
@@ -311,8 +316,7 @@ class DownloaderHelper(_PluginBase):
'content': [{
'component': 'VCol',
'props': {
'cols': 12,
'title': 'Tracker映射。用于在站点打标签时指定tracker和站点域名不同的种子的域名对应关系前面为tracker域名二级或多级中间是英文冒号后面是站点域名只能是二级'
'cols': 12
},
'content': [{
'component': 'VTextarea',
@@ -322,7 +326,8 @@ class DownloaderHelper(_PluginBase):
'placeholder': '格式:\n'
'<tracker-domain>:<site-domain>\n'
'例如:\n'
'chdbits.xyz:ptchdbits.co'
'chdbits.xyz:ptchdbits.co',
'hint': 'Tracker映射。用于在站点打标签时指定tracker和站点域名不同的种子的域名对应关系前面为tracker域名完整域名或者主域名皆可中间是英文冒号后面是站点域名。'
}
}]
}]
@@ -371,7 +376,8 @@ class DownloaderHelper(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'qb_enable',
'label': '任务开关'
'label': '任务开关',
'hint': '该下载器子任务的开关'
}
}]
}, {
@@ -384,7 +390,8 @@ class DownloaderHelper(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'qb_enable_seeding',
'label': '自动做种'
'label': '自动做种',
'hint': '是否开启自动做种功能'
}
}]
}, {
@@ -397,7 +404,8 @@ class DownloaderHelper(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'qb_enable_tagging',
'label': '站点标签'
'label': '站点标签',
'hint': '是否开启站点标签功能'
}
}]
}, {
@@ -410,7 +418,8 @@ class DownloaderHelper(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'qb_enable_delete',
'label': '自动删种'
'label': '自动删种',
'hint': '是否开启自动删种功能'
}
}]
}]
@@ -516,8 +525,7 @@ class DownloaderHelper(_PluginBase):
finally:
self.__exit_event.clear()
@staticmethod
def __parse_tracker_mappings(tracker_mappings: str) -> Dict[str, str]:
def __parse_tracker_mappings(self, tracker_mappings: str) -> Dict[str, str]:
"""
解析配置的tracker映射
:param tracker_mappings: 配置的tracker映射
@@ -540,7 +548,7 @@ class DownloaderHelper(_PluginBase):
key, value = key.strip(), value.strip()
if not key or not value:
continue
if len(key.split('.')) >= 2 and len(value.split('.')) == 2:
if self.__is_valid_domain(key) and self.__is_valid_domain(value):
mappings[key] = value
return mappings
@@ -767,34 +775,60 @@ class DownloaderHelper(_PluginBase):
scheme, netloc = StringUtils.get_url_netloc(url)
return netloc
@staticmethod
def __get_domain_level2(domain: str) -> Optional[str]:
def __get_main_domain(self, domain: str) -> Optional[str]:
"""
获取域名的二级域名
获取域名的域名
:param domain: 原域名
:return: 主域名
"""
if not domain:
return None
domain_arr = domain.split('.')
domain_arr_len = len(domain_arr)
if domain_arr_len == 2:
return domain
elif domain_arr_len > 2:
return f'{domain_arr[-2]}.{domain_arr[-1]}'
else:
domain_len = len(domain_arr)
if domain_len < 2:
return None
root_domain, root_domain_len = self.__match_multi_level_root_domain(domain=domain)
if root_domain:
return f'{domain_arr[-root_domain_len - 1]}.{root_domain}'
else:
return f'{domain_arr[-2]}.{domain_arr[-1]}'
@staticmethod
def __get_domain_keyword(domain: str) -> Optional[str]:
def __get_domain_keyword(self, domain: str) -> Optional[str]:
"""
获取域名关键字
"""
main_domain = self.__get_main_domain(domain=domain)
if not main_domain:
return None
return main_domain.split('.')[0]
def __match_multi_level_root_domain(self, domain: str) -> Tuple[Optional[str], int]:
"""
匹配多级根域名
:param domain: 被匹配的域名
:return: 匹配的根域名, 匹配的根域名长度
"""
if not domain or not self.__multi_level_root_domain:
return None, 0
for root_domain in self.__multi_level_root_domain:
if domain.endswith('.' + root_domain):
root_domain_len = len(root_domain.split('.'))
return root_domain, root_domain_len
return None, 0
def __is_valid_domain(self, domain: str) -> bool:
"""
判断域名是否有效
:param domain: 被判断的域名
:return: 是否有效
"""
if not domain:
return None
domain_arr = domain.split('.')
if len(domain_arr) >= 2:
return domain_arr[-2]
else:
return None
return False
domain_len = len(domain.split('.'))
root_domain, root_domain_len = self.__match_multi_level_root_domain(domain)
if root_domain:
return domain_len > root_domain_len
return domain_len > 1
def __generate_site_tag(self, site: str) -> Optional[str]:
"""
@@ -816,7 +850,7 @@ class DownloaderHelper(_PluginBase):
return None, None
# tracker的完整域名
tracker_domain = self.__get_url_domain(tracker_url)
tracker_domain = self.__get_url_domain(url=tracker_url)
if not tracker_domain:
return None, None
@@ -824,20 +858,20 @@ class DownloaderHelper(_PluginBase):
delete_suggest = set()
# tracker域名关键字
tracker_domain_keyword = self.__get_domain_keyword(tracker_domain)
tracker_domain_keyword = self.__get_domain_keyword(domain=tracker_domain)
if tracker_domain_keyword:
# 建议移除
delete_suggest.add(tracker_domain_keyword)
delete_suggest.add(self.__generate_site_tag(tracker_domain_keyword))
delete_suggest.add(self.__generate_site_tag(site=tracker_domain_keyword))
# 首先根据tracker的完整域名去匹配站点信息
site_info = self.__get_site_info_by_domain(tracker_domain)
site_info = self.__get_site_info_by_domain(site_domain=tracker_domain)
# 如果没有匹配到,再根据二级域名去匹配
# 如果没有匹配到,再根据域名去匹配
if not site_info:
tracker_domain_level2 = self.__get_domain_level2(tracker_domain)
if tracker_domain_level2:
site_info = self.__get_site_info_by_domain(tracker_domain_level2)
tracker_main_domain = self.__get_main_domain(domain=tracker_domain)
if tracker_main_domain and tracker_main_domain != tracker_domain:
site_info = self.__get_site_info_by_domain(tracker_main_domain)
# 如果还是没有匹配到就根据tracker映射的域名匹配
matched_site_domain = None
@@ -872,7 +906,7 @@ class DownloaderHelper(_PluginBase):
else:
site_tag = self.__generate_site_tag(self.__get_domain_keyword(tracker_domain))
if site_tag:
if site_tag and site_tag in delete_suggest:
delete_suggest.remove(site_tag)
return site_tag, delete_suggest
@@ -1444,7 +1478,8 @@ class DownloaderHelper(_PluginBase):
# 移除建议删除的标签
if delete_suggest and len(delete_suggest) > 0:
for to_delete in delete_suggest:
torrent_tags_copy.remove(to_delete)
if to_delete and to_delete in torrent_tags_copy:
torrent_tags_copy.remove(to_delete)
# 如果本次需要打标签
if site_tag and site_tag not in torrent_tags_copy:
torrent_tags_copy.append(site_tag)
@@ -1513,12 +1548,12 @@ class DownloaderHelper(_PluginBase):
# 执行
logger.info('下载添加事件监听任务执行开始...')
context = TaskContext().enable_seeding(False).enable_tagging(True).enable_delete(False)
hash_str = event.event_data.get('hash')
if hash:
context.select_torrent(hash_str)
_hash = event.event_data.get('hash')
if _hash:
context.select_torrent(torrent=_hash)
username = event.event_data.get('username')
if username:
context.select_username(username)
context.set_username(username=username)
self.__run_for_all(context=context)
logger.info('下载添加事件监听任务执行结束')

View File

@@ -0,0 +1,263 @@
import base64
import hashlib
import hmac
import time
from typing import Any, List, Dict, Tuple
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType, NotificationType
from app.utils.http import RequestUtils
class FeiShuMsg(_PluginBase):
# 插件名称
plugin_name = "飞书机器人消息通知"
# 插件描述
plugin_desc = "支持使用飞书群聊机器人发送消息通知。"
# 插件图标
plugin_icon = "FeiShu_A.png"
# 插件版本
plugin_version = "1.0"
# 插件作者
plugin_author = "InfinityPacer"
# 作者主页
author_url = "https://github.com/InfinityPacer"
# 插件配置项ID前缀
plugin_config_prefix = "feishu_"
# 加载顺序
plugin_order = 28
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
_webhookurl = None
_msgtypes = []
_secret = None
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._webhookurl = config.get("webhookurl")
self._msgtypes = config.get("msgtypes") or []
self._secret = config.get("secret")
def get_state(self) -> bool:
return self._enabled and (True if self._webhookurl else False)
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
# 遍历 NotificationType 枚举,生成消息类型选项
msg_type_options = []
default_msg_type_values = []
for item in NotificationType:
msg_type_options.append({
"title": item.value,
"value": item.name
})
default_msg_type_values.append(item.name)
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'webhookurl',
'label': 'WebHook地址',
'placeholder': 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'secret',
'label': '密钥',
'placeholder': '如设置了签名校验,请输入密钥',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VSelect',
'props': {
'multiple': True,
'chips': True,
'model': 'msgtypes',
'label': '消息类型',
'items': msg_type_options
}
}
]
}
]
},
]
}
], {
"enabled": False,
'webhookurl': '',
'msgtypes': default_msg_type_values,
'secret': '',
}
def get_page(self) -> List[dict]:
pass
@eventmanager.register(EventType.NoticeMessage)
def send(self, event: Event):
"""
消息发送事件
"""
if not self.get_state():
return
if not event.event_data:
return
msg_body = event.event_data
# 渠道
channel = msg_body.get("channel")
if channel:
logger.info(f"channel: {channel} 不进行消息推送")
return
# 类型
msg_type: NotificationType = msg_body.get("type")
# 标题
title = msg_body.get("title")
# 文本
text = msg_body.get("text")
# 图像
image = msg_body.get("image")
if not title and not text:
logger.warn("标题和内容不能同时为空")
return
if (msg_type and self._msgtypes
and msg_type.name not in self._msgtypes):
logger.info(f"消息类型 {msg_type.value} 未开启消息发送")
return
try:
payload = {
"msg_type": "post",
"content": {
"post": {
"zh_cn": {
"title": title,
"content": [
[{
"tag": "text",
"text": text
}]
]
}
}
}
}
# 如果存在密钥时,还需要进行签名处理
if self._secret:
timestamp = str(int(time.time()))
sign = self.gen_sign(timestamp, self._secret)
payload.update({
"timestamp": timestamp,
"sign": sign
})
res = RequestUtils(content_type="application/json").post_res(url=self._webhookurl, json=payload)
if res and res.status_code == 200:
ret_json = res.json()
errno = ret_json.get('code')
error = ret_json.get('msg')
if errno == 0:
logger.info("飞书机器人消息发送成功")
else:
logger.warn(f"飞书机器人消息发送失败,错误码:{errno},错误原因:{error}")
elif res is not None:
logger.warn(f"飞书机器人消息发送失败,错误码:{res.status_code},错误原因:{res.reason}")
else:
logger.warn("飞书机器人消息发送失败,未获取到返回信息")
except Exception as msg_e:
logger.error(f"飞书机器人消息发送失败,{str(msg_e)}")
def stop_service(self):
"""
退出插件
"""
pass
@staticmethod
def gen_sign(timestamp, secret):
# 拼接timestamp和secret
string_to_sign = '{}\n{}'.format(timestamp, secret)
hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest()
# 对结果进行base64处理
sign = base64.b64encode(hmac_code).decode('utf-8')
return sign

View File

@@ -1 +1 @@
roman~=4.1
roman~=4.1