├── server ├── .prettierrc ├── nest-cli.json ├── tsconfig.build.json ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts ├── src │ ├── main.ts │ ├── modules │ │ └── twitter.server.module.ts │ ├── controllers │ │ ├── screenshot.service.ts │ │ ├── screenshot.controller.ts │ │ ├── translate.service.ts │ │ ├── translate.controller.ts │ │ └── utils │ │ │ ├── rawScreenshot.ts │ │ │ ├── preparePage.ts │ │ │ └── addTranslation.ts │ ├── css │ │ └── emoji.css │ └── component │ │ ├── Text.ts │ │ └── Card.ts ├── tsconfig.json ├── .gitignore ├── .eslintrc.js ├── package.json └── README.md ├── bin └── config.db ├── groups ├── tags │ └── default_tag.png ├── css │ └── default_text.css └── settings │ └── 887867063.json ├── .gitignore ├── bot ├── config.py ├── bot.py ├── settings.json ├── help.json └── addon │ ├── group_log.py │ ├── settings.py │ ├── __init__.py │ ├── server.py │ ├── listener.py │ ├── group_settings.py │ ├── db_holder.py │ ├── tweet_holder.py │ └── commands.py ├── tools └── changeGroupSettings.py └── README.md /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /bin/config.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkZH0740/retweet-qq-bot/HEAD/bin/config.db -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /groups/tags/default_tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkZH0740/retweet-qq-bot/HEAD/groups/tags/default_tag.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | /idea/ 3 | *.ipr 4 | *.iml 5 | *.iws 6 | /server/node_modules/ 7 | /server/dist/ 8 | /server/src/cache/ 9 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /bot/config.py: -------------------------------------------------------------------------------- 1 | from nonebot.default_config import * 2 | 3 | HOST = '127.0.0.1' 4 | PORT = 12138 5 | COMMAND_START = {'#'} 6 | SUPERUSERS = {2267980149} 7 | DEBUG = False -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { TwitterServerModule } from './modules/twitter.server.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(TwitterServerModule); 6 | await app.listen(3000); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /groups/css/default_text.css: -------------------------------------------------------------------------------- 1 | .text{ 2 | margin-left: 0px; margin-right: 0px; 3 | padding-left: 0px; padding-right: 0px; padding-top: 0px; padding-bottom: 0px; 4 | left: 0px; top: 0px; right: 0px; bottom: 0px; 5 | display: inline; 6 | vertical-align: top; 7 | overflow-wrap: break-word; 8 | } -------------------------------------------------------------------------------- /bot/bot.py: -------------------------------------------------------------------------------- 1 | import nonebot, config 2 | from os import path 3 | from nonebot.log import logging, logger 4 | 5 | if __name__ == '__main__': 6 | # 启动bot 7 | nonebot.init(config) 8 | nonebot.load_builtin_plugins() 9 | nonebot.load_plugins(path.join(path.dirname(__file__), 'addon'), 'addon') 10 | nonebot.run() 11 | -------------------------------------------------------------------------------- /groups/settings/887867063.json: -------------------------------------------------------------------------------- 1 | { 2 | "tweet": true, 3 | "retweet": true, 4 | "comment": true, 5 | "original_text": true, 6 | "translate": true, 7 | "content": true, 8 | "tag_path": "C:\\Users\\Administrator\\Desktop\\screenshotBot\\groups\\tags\\default_tag.png", 9 | "css_path": "C:\\Users\\Administrator\\Desktop\\screenshotBot\\groups\\css\\887867063_text.css" 10 | } -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /bot/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "server-url": "your screenshot server address", 3 | "project-path": "your project path", 4 | "twitter-api": { 5 | "consumer-key": "your consumer key", 6 | "consumer-secret": "your consumer secret", 7 | "access-token": "your access token key", 8 | "access-token-secret": "your access token secret" 9 | }, 10 | "baidu-translation": { 11 | "api": "baidu translation api", 12 | "secret": "baidu translation key" 13 | } 14 | } -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /tools/changeGroupSettings.py: -------------------------------------------------------------------------------- 1 | import json, os 2 | 3 | osList = os.listdir(".//groups") 4 | 5 | for file in osList: 6 | if file.find(".json") != -1: 7 | with open(f".//groups//{file}", "r", encoding="utf-8") as f: 8 | prev = json.load(f) 9 | tag: str = prev["tag"] 10 | tag = tag.replace("mike", "Administrator") 11 | prev["tag"] = tag 12 | with open(f".//groups//{file}", "w", encoding="utf-8") as f: 13 | json.dump(prev, f, ensure_ascii=False, indent=1) 14 | print(f"SOLVED => {file}") -------------------------------------------------------------------------------- /server/src/modules/twitter.server.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ScreenshotController } from "src/controllers/screenshot.controller"; 3 | import { ScreenshotService } from "src/controllers/screenshot.service"; 4 | import { TranslationController } from "src/controllers/translate.controller"; 5 | import { TranslationService } from "src/controllers/translate.service"; 6 | 7 | 8 | @Module({ 9 | controllers: [ScreenshotController, TranslationController], 10 | providers: [ScreenshotService, TranslationService] 11 | }) 12 | export class TwitterServerModule {} -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 转推机器人 2 | 3 | 从QQ操作关注推特用户的推文截图的机器人。SoreHaitACE & sudo 编写。 4 | 需要酷Q PRO实现向QQ发送图片。 5 | 6 | 能够从QQ: 7 | 8 | 1. 增加/删除对于推特用户的监听 9 | 10 | 2. 操作是否需要推特的原文,翻译,内含图片;是否需要截取转推,评论和发推 11 | 12 | 3. 实现简单的嵌字和嵌入emoji,简单改变嵌字的样式 13 | 14 | ## 注意事项 15 | 16 | 需要的python包: 17 | pip install nonebot 18 | pip install nonebot[scheduler] 19 | pip install tweepy 20 | 21 | 需要的npm包: 22 | npm i @nestjs/cli 23 | npm i @types/puppeteer-core 24 | npm i @types/twemoji 25 | npm i @types/tmp 26 | 27 | 需要酷Q PRO 28 | 29 | 需要自己申请twitter developer账户并创建自己的app,申请自己的百度翻译app,然后替换bot\\settings.json中对应的项目 30 | 31 | 需要修改项目路径,位于bot\\settings.json 32 | 33 | 如果需要修改截图服务器地址和端口,也位于bot\\settings.json 34 | 35 | 请按照QQ机器人的help文档输入命令 36 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/src/controllers/screenshot.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Browser } from 'puppeteer-core'; 3 | import { PageHolder } from './utils/preparePage'; 4 | var AsyncLock = require('async-lock'); 5 | 6 | const lock = new AsyncLock; 7 | 8 | @Injectable() 9 | export class ScreenshotService{ 10 | public browser: Browser; 11 | 12 | async takeScreenshot(url: string){ 13 | let currPage = new PageHolder(this.browser, url); 14 | await currPage.preload(); 15 | 16 | return lock.acquire("lock", () => { 17 | return (async ()=>{ 18 | return await currPage.screenshot().catch((err) => { 19 | currPage.free("takescreenshot err"); 20 | return {status: false, msg: err}; 21 | }) 22 | })(); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/src/controllers/screenshot.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Body } from '@nestjs/common'; 2 | import { ScreenshotService } from './screenshot.service'; 3 | import { launch, Browser } from 'puppeteer-core'; 4 | import { chromePath } from './utils/preparePage'; 5 | 6 | const browser: Promise = launch({ product:"chrome", executablePath: chromePath }); 7 | 8 | 9 | @Controller("screenshot") 10 | export class ScreenshotController{ 11 | constructor(private readonly appService: ScreenshotService) {} 12 | 13 | @Get() 14 | public async screenshot(@Body("url") url: string){ 15 | console.log(`${url} received!`); 16 | this.appService.browser = await browser; 17 | let result = await this.appService.takeScreenshot(url).catch((err) => { 18 | return {status:false, msg: `unknown ${err} @ controller`}; 19 | }); 20 | console.log(`returned ${result}`); 21 | return result; 22 | } 23 | } -------------------------------------------------------------------------------- /server/src/controllers/translate.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Browser } from 'puppeteer-core'; 3 | import { PageHolder } from './utils/preparePage'; 4 | 5 | var AsyncLock = require('async-lock'); 6 | 7 | const lock = new AsyncLock; 8 | 9 | @Injectable() 10 | export class TranslationService{ 11 | public browser: Browser; 12 | 13 | async addTranslation(url: string, translation: string, cssPath: string, tagPath: string){ 14 | let currPage = new PageHolder(this.browser, url); 15 | await currPage.preload(); 16 | 17 | return lock.acquire("lock", () => { 18 | return (async ()=>{ 19 | return await currPage.translation(translation, cssPath, tagPath).catch((err) => { 20 | currPage.free("addtranslation err"); 21 | console.log(err); 22 | return { status: false, msg: err}; 23 | }) 24 | })(); 25 | }); 26 | } 27 | } -------------------------------------------------------------------------------- /server/src/controllers/translate.controller.ts: -------------------------------------------------------------------------------- 1 | import { launch, Browser } from 'puppeteer-core'; 2 | import { chromePath } from './utils/preparePage'; 3 | import { Controller, Post, Body } from '@nestjs/common'; 4 | import { TranslationService } from './translate.service'; 5 | 6 | const browser: Promise = launch({ product: "chrome", executablePath: chromePath }); 7 | 8 | 9 | @Controller("translation") 10 | export class TranslationController { 11 | constructor(private readonly appService: TranslationService) { } 12 | 13 | @Post() 14 | public async translate(@Body("url") url: string, 15 | @Body("translation") translation: string, 16 | @Body("css-path") cssPath: string, 17 | @Body("tag-path") tagPath: string) 18 | { 19 | console.log(`${url} received!`); 20 | this.appService.browser = await browser; 21 | let result = await this.appService.addTranslation(url, translation, cssPath, tagPath).catch((err) => { 22 | return { status: false, msg: `unknown ${err} @ controller`}; 23 | }); 24 | console.log(`returned ${result}`); 25 | return result; 26 | } 27 | } -------------------------------------------------------------------------------- /bot/help.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": "目前已支持命令:add, enable, disable, translation(tr), tag, css, checkcss, help, freetranslate(ftr), screenshot;\n <>必选参数,[]可选参数", 3 | "add": "加入监听推特用户,使用方法#add ;", 4 | "enable": "打开对应功能,使用方法#enable <功能名>,目前支持[tweet(普通发推内容), retweet(转推内容), comment(评论内容), originalText(原文), translate(翻译), contentPicture(推内发出的图片)]", 5 | "disable": "关闭对应功能,使用方法#disable <功能名>,目前支持[tweet(普通发推内容), retweet(转推内容), comment(评论内容), originalText(原文), translate(翻译), contentPicture(推内发出的图片)]", 6 | "translation": "翻译推文,使用方法#translate <嵌字编号>,随后输入翻译文本块,如果是翻译回复推文,请使用#行号 来标记从上至下第几条的文本,例如\n#1 第一行内容\n#2 \n第二行内容\n#5 第五行内容", 7 | "tag": "指定在推文主要内容位置嵌字的标签(在嵌字文本上面加一个小图片tag),使用方法#tag <图片>", 8 | "css": "指定嵌字文本的样式,请输入标准css文本,使用方法#css .text{}", 9 | "checkcss": "获取当前使用的css文本,使用方法#checkcss", 10 | "freetranslate": "根据推文url进行签字,使用方法#frt ,随后输入翻译文本块", 11 | "screenshot": "根据推文url获取截图,使用方法#screenshot ,随后输入翻译文本块", 12 | "help": "获取帮助文本,使用方法#help [命令名称]", 13 | "new": "新增了[css, checkcss, freetranslate(ftr), screenshot]方法,移除了style方法。\n使用css直接使用css样式指定嵌字文本样式,checkcss查看当前使用的css样式,ftr使用url直接嵌字,screenshot使用url直接截图。\n(也许)修复了嵌评论时行标后不能换行的bug,修改了emoji在页面上的展示方法,现在emoji在文本中更加自然了。" 14 | } -------------------------------------------------------------------------------- /bot/addon/group_log.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .settings import SETTING 4 | 5 | 6 | def add_group_log(group_id: str, url: str) -> int: 7 | """ 8 | 加入群日志 9 | :param group_id: 群号 10 | :param url: 推文链接 11 | :return: 行数,即嵌字编号 12 | """ 13 | log_path = f"{SETTING.group_log_path}\\{group_id}.txt" 14 | if not os.path.exists(log_path): 15 | # 群日志文件不存在,新建日志文件 16 | open(log_path, "w", encoding="utf-8") 17 | with open(log_path, "r+", encoding="utf-8") as f: 18 | # 读取原有日志 19 | previous_content = f.readlines() 20 | # 加入新纪录 21 | previous_content.append(f"{url}\n") 22 | index = len(previous_content) 23 | if index > 5000: 24 | # 长于5000行,删除前5000行重新开始记录 25 | with open(log_path, "w", encoding="utf-8") as f: 26 | f.writelines(previous_content[5000:]) 27 | index -= 5000 28 | else: 29 | with open(log_path, "a+", encoding="utf-8") as f: 30 | f.write(previous_content.pop()) 31 | return index 32 | 33 | 34 | def read_group_log(group_id: str, index: int) -> str: 35 | """ 36 | 读取群日志 37 | :param group_id: 群号 38 | :param index: 日志记录的真实序号(-1) 39 | :return: 记录的推文链接 40 | """ 41 | log_path = f"{SETTING.group_log_path}\\{group_id}.txt" 42 | with open(log_path, "r", encoding="utf-8") as f: 43 | previous_content = f.readlines() 44 | if index < 0 or index > len(previous_content) - 1: 45 | raise RuntimeError("嵌字编号不合法!") 46 | return previous_content[index].strip() 47 | -------------------------------------------------------------------------------- /server/src/css/emoji.css: -------------------------------------------------------------------------------- 1 | .emoji-span{ 2 | margin-left: 0.075em; margin-right: 0.075em; margin-top: 0px; margin-bottom: 0px; 3 | padding-left: 0px; padding-right: 0px; padding-top: 0px; padding-bottom: 0px; 4 | left: 0px; top: 0px; right: 0px; bottom: 0px; 5 | display: inline-block; 6 | vertical-align: top; 7 | position: relative; 8 | height: 1em; width: 1.2em; 9 | } 10 | 11 | .emoji-div{ 12 | margin-left: 0px; margin-right: 0px; margin-top: 0px; margin-bottom: 0px; 13 | padding-left: 0px; padding-right: 0px; padding-top: 0px; padding-bottom: 0px; 14 | left: 0px; top: 0px; right: 0px; bottom: 0px; 15 | flex-direction: column; flex-shrink: 0; 16 | overflow: hidden, hidden; 17 | display: inline-flex; 18 | position: absolute; 19 | z-index: 0; 20 | height: 1.2em; width: 1.2em; 21 | } 22 | 23 | .emoji-back{ 24 | margin-left: 0px; margin-right: 0px; margin-top: 0px; margin-bottom: 0px; 25 | padding-left: 0px; padding-right: 0px; padding-top: 0px; padding-bottom: 0px; 26 | left: 0px; top: 0px; right: 0px; bottom: 0px; 27 | flex-direction: column; 28 | display: inline-flex; 29 | position: absolute; 30 | z-index: -1; 31 | background-position: center center; 32 | background-repeat: no-repeat; 33 | background-size: 100%, 100%; 34 | } 35 | 36 | .emoji-fore{ 37 | margin-left: 0px; margin-right: 0px; margin-top: 0px; margin-bottom: 0px; 38 | padding-left: 0px; padding-right: 0px; padding-top: 0px; padding-bottom: 0px; 39 | left: 0px; top: 0px; right: 0px; bottom: 0px; 40 | position: absolute; 41 | z-index: -1; 42 | height: 100%; width: 100%; 43 | } 44 | 45 | .tag-img{ 46 | height: 1.2em 47 | } -------------------------------------------------------------------------------- /server/src/controllers/utils/rawScreenshot.ts: -------------------------------------------------------------------------------- 1 | import { BoundingBox } from "puppeteer-core"; 2 | import { PageHolder } from "./preparePage"; 3 | import { TwitterMessage } from "src/component/Text"; 4 | import { tmpNameSync } from "tmp"; 5 | 6 | 7 | export async function rawTakeScreenshot(pageHolder: PageHolder, mainTweetIndex: number = -1, needContent: boolean = true){ 8 | const page = pageHolder.page; 9 | 10 | let articles = await page.$$("article"); 11 | let content = ""; 12 | let boundingBox: BoundingBox; 13 | 14 | for (let i = 0; i < articles.length; i++) { 15 | const element = articles[i]; 16 | const isMainTweet = await element.$eval("div[data-testid='tweet']", (tweetBoard) => { 17 | return tweetBoard.parentElement.childElementCount > 2; 18 | }) 19 | 20 | if (i == 0) 21 | boundingBox = await element.boundingBox(); 22 | else 23 | boundingBox.height += (await element.boundingBox()).height; 24 | 25 | if (needContent){ 26 | let twitterMsg = new TwitterMessage(element); 27 | await twitterMsg.init(); 28 | content += `第${i + 1}行: ${twitterMsg.toString()}`; 29 | } 30 | 31 | if ((mainTweetIndex == -1 && isMainTweet) || (i == mainTweetIndex)){ 32 | break; 33 | } 34 | } 35 | 36 | let tempfileName = tmpNameSync({ postfix:".png", tmpdir: ".\\src\\cache"}); 37 | try{ 38 | await page.screenshot({ path: tempfileName, clip: boundingBox}); 39 | }catch (err){ 40 | return { status: false, msg: `@ rawScreenshot => ${err}`}; 41 | } 42 | 43 | return { status: true, msg: tempfileName, content: content}; 44 | } -------------------------------------------------------------------------------- /bot/addon/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class Settings: 5 | """ 6 | 该类保存bot运行的所有设置 7 | """ 8 | debug = True 9 | 10 | server_url: str 11 | project_path: str 12 | 13 | group_log_path: str 14 | config_path: str 15 | group_tag_path: str 16 | group_css_path: str 17 | group_setting_path: str 18 | tweet_log_path: str 19 | 20 | consumer_key: str 21 | consumer_secret: str 22 | access_token: str 23 | access_token_secret: str 24 | 25 | baidu_api: str 26 | baidu_secret: str 27 | 28 | def __init__(self): 29 | with open("settings.json", "r", encoding="utf-8") as f: 30 | raw_settings = json.load(f) 31 | 32 | self.server_url = raw_settings["server-url"] 33 | self.project_path = raw_settings["project-path"] 34 | 35 | self.group_log_path = f"{self.project_path}\\groups\\logs" 36 | self.config_path = f"{self.project_path}\\bin\\config.db" 37 | self.group_tag_path = f"{self.project_path}\\groups\\tags" 38 | self.group_css_path = f"{self.project_path}\\groups\\css" 39 | self.group_setting_path = f"{self.project_path}\\groups\\settings" 40 | self.tweet_log_path = f"{self.project_path}\\cache" 41 | 42 | self.consumer_key = raw_settings["twitter-api"]["consumer-key"] 43 | self.consumer_secret = raw_settings["twitter-api"]["consumer-secret"] 44 | self.access_token = raw_settings["twitter-api"]["access-token"] 45 | self.access_token_secret = raw_settings["twitter-api"]["access-token-secret"] 46 | 47 | self.baidu_api = raw_settings['baidu-translation']['api'] 48 | self.baidu_secret = raw_settings['baidu-translation']['secret'] 49 | 50 | 51 | # 全局设置代理 52 | SETTING = Settings() 53 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-2", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^7.0.0", 25 | "@nestjs/core": "^7.0.0", 26 | "@nestjs/platform-express": "^7.0.0", 27 | "@types/async-lock": "^1.1.2", 28 | "@types/puppeteer-core": "^2.0.0", 29 | "@types/tmp": "^0.2.0", 30 | "@types/twemoji": "^12.1.1", 31 | "async-lock": "^1.2.4", 32 | "puppeteer-core": "^5.2.1", 33 | "reflect-metadata": "^0.1.13", 34 | "rimraf": "^3.0.2", 35 | "rxjs": "^6.5.4", 36 | "tmp": "^0.2.1", 37 | "twemoji": "^13.0.1" 38 | }, 39 | "devDependencies": { 40 | "@nestjs/cli": "^7.0.0", 41 | "@nestjs/schematics": "^7.0.0", 42 | "@nestjs/testing": "^7.0.0", 43 | "@types/express": "^4.17.3", 44 | "@types/jest": "25.2.3", 45 | "@types/node": "^13.9.1", 46 | "@types/supertest": "^2.0.8", 47 | "@typescript-eslint/eslint-plugin": "3.0.2", 48 | "@typescript-eslint/parser": "3.0.2", 49 | "eslint": "7.1.0", 50 | "eslint-config-prettier": "^6.10.0", 51 | "eslint-plugin-import": "^2.20.1", 52 | "jest": "26.0.1", 53 | "prettier": "^1.19.1", 54 | "supertest": "^4.0.2", 55 | "ts-jest": "26.1.0", 56 | "ts-loader": "^6.2.1", 57 | "ts-node": "^8.6.2", 58 | "tsconfig-paths": "^3.9.0", 59 | "typescript": "^3.7.4" 60 | }, 61 | "jest": { 62 | "moduleFileExtensions": [ 63 | "js", 64 | "json", 65 | "ts" 66 | ], 67 | "rootDir": "src", 68 | "testRegex": ".spec.ts$", 69 | "transform": { 70 | "^.+\\.(t|j)s$": "ts-jest" 71 | }, 72 | "coverageDirectory": "../coverage", 73 | "testEnvironment": "node" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /bot/addon/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from nonebot import get_bot, on_command, CommandSession, scheduler 4 | from tweepy import OAuthHandler, API, Stream 5 | 6 | from .listener import MyListener 7 | from .settings import SETTING 8 | from .db_holder import databse 9 | from .tweet_holder import Wrapper 10 | 11 | bot = get_bot() 12 | auth = OAuthHandler(SETTING.consumer_key, SETTING.consumer_secret) 13 | auth.set_access_token(SETTING.access_token, SETTING.access_token_secret) 14 | api = API(auth) 15 | listener = MyListener(api) 16 | 17 | stream_holder = [""] 18 | stream_holder[0] = Stream(auth=api.auth, listener=listener) 19 | stream_holder[0].filter(follow=listener.followed_users, is_async=True) 20 | 21 | wrapper = Wrapper() 22 | wrapper.load() 23 | wrapper.start() 24 | 25 | 26 | def get_twitter_id(screen_name: str) -> str: 27 | """ 28 | 爬取screen name对应的twitter id, 29 | :param screen_name: twitter用户的screen name 30 | :return: 推特用户的twitter id 31 | """ 32 | res = requests.post(url="https://tweeterid.com/ajax.php", data=f"input={screen_name.lower()}", headers={ 33 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}).content.decode("utf-8") 34 | return res 35 | 36 | 37 | def refresh_stream(): 38 | """ 39 | 刷新当前的tweepy流 40 | """ 41 | stream_holder[0].disconnect() 42 | # 重取监听对象的twitter id 43 | listener.reload_folloed_users() 44 | stream_holder[0] = Stream(api.auth, listener) 45 | stream_holder[0].filter(follow=listener.followed_users, is_async=True) 46 | 47 | 48 | @on_command("add", only_to_me=False) 49 | async def add_command(session: CommandSession): 50 | screen_name = session.current_arg_text.strip() 51 | twitter_id = get_twitter_id(screen_name) 52 | if not twitter_id.isdigit(): 53 | # 错误的screen name会返回"error" 54 | session.finish("请输入正确的screen name") 55 | databse.add_user(screen_name, twitter_id, str(session.event.group_id)) 56 | refresh_stream() 57 | session.finish("成功!") 58 | 59 | 60 | @on_command("delete", only_to_me=False) 61 | async def delete_command(session: CommandSession): 62 | screen_name = session.current_arg_text.strip() 63 | databse.delete_user(screen_name, str(session.event.group_id)) 64 | refresh_stream() 65 | session.finish("成功!") 66 | 67 | 68 | @scheduler.scheduled_job('interval', seconds=60) 69 | async def _schedule(): 70 | """ 71 | 每60秒检查streamlistener实例是否遇到错误并重启 72 | """ 73 | curr_errlist = listener.get_all_errors() 74 | if len(curr_errlist) > 0: 75 | refresh_stream() 76 | await bot.send_private_msg(user_id=2267980149, message=str(curr_errlist)) 77 | -------------------------------------------------------------------------------- /bot/addon/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | import urllib 5 | import http 6 | import hashlib 7 | 8 | from requests_futures.sessions import FuturesSession 9 | 10 | from .group_settings import GroupSetting 11 | from .settings import SETTING 12 | 13 | session = FuturesSession() 14 | 15 | 16 | async def take_screenshot(url: str) -> dict: 17 | """ 18 | 向服务端发送截图请求 19 | :param url: 推文链接 20 | :return: 服务端截图结果 21 | """ 22 | get = session.get(f"{SETTING.server_url}/screenshot", 23 | json={"url": url}).result() 24 | return json.loads(get.content.decode("utf-8")) 25 | 26 | 27 | async def add_translation(url: str, translation: str, group_setting: dict) -> dict: 28 | """ 29 | 向服务端发送嵌字请求 30 | :param url: 推文链接 31 | :param translation: 翻译文本 32 | :param group_setting: 群设置 33 | :return: 服务端嵌字结果 34 | """ 35 | post_data = { 36 | "url": url, 37 | "translation": translation, 38 | "css-path": group_setting["css_path"], 39 | "tag-path": group_setting["tag_path"] 40 | } 41 | post = session.post( 42 | f"{SETTING.server_url}/translation", json=post_data).result() 43 | return json.loads(post.content.decode("utf-8")) 44 | 45 | 46 | async def baidu_translation(content: str) -> str: 47 | """ 48 | 百度翻译 49 | :author: SoreHait ACE 50 | :param content: 翻译内容 51 | :return: 翻译结果 52 | """ 53 | http_client = None 54 | myurl = '/api/trans/vip/translate' 55 | qaa = str(content) 56 | qaa = qaa.replace('\n', '') 57 | from_lang = 'auto' 58 | to_lang = 'zh' 59 | salt = random.randint(32768, 65536) 60 | sign = SETTING.baidu_api + qaa + str(salt) + SETTING.baidu_secret 61 | m1 = hashlib.md5() 62 | m2 = sign.encode(encoding='utf-8') 63 | m1.update(m2) 64 | sign = m1.hexdigest() 65 | myurl = myurl + '?appid=' + SETTING.baidu_api + '&q=' + urllib.parse.quote( 66 | qaa) + '&from=' + from_lang + '&to=' + to_lang + '&salt=' + str(salt) + '&sign=' + sign 67 | try: 68 | http_client = http.client.HTTPConnection('api.fanyi.baidu.com') 69 | http_client.request('GET', myurl) 70 | response = http_client.getresponse() 71 | resp = str(response.read()) 72 | resu = str(re.findall('"dst":"(.+?)"', resp)[0]) 73 | resul = resu.encode('utf-8').decode('unicode_escape') 74 | resultr = resul.encode('utf-8').decode('unicode_escape') 75 | result = resultr.replace(r'\/', r'/') 76 | return result 77 | except Exception: 78 | return '翻译api超速,获取失败' 79 | finally: 80 | if http_client: 81 | http_client.close() 82 | -------------------------------------------------------------------------------- /server/src/controllers/utils/preparePage.ts: -------------------------------------------------------------------------------- 1 | import { Page, Browser } from "puppeteer-core"; 2 | import { rawTakeScreenshot } from "./rawScreenshot"; 3 | import { addTranslation } from "./addTranslation"; 4 | import { readFileSync } from "fs"; 5 | 6 | export const chromePath = "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"; 7 | 8 | export class PageHolder{ 9 | public browser: Browser; 10 | public page: Page; 11 | 12 | public url: string; 13 | 14 | 15 | public constructor(browser: Browser, url: string){ 16 | this.browser = browser; 17 | this.url = url; 18 | } 19 | 20 | public async free(from: string){ 21 | if (!this.page.isClosed()) 22 | await this.page.close(); 23 | console.log(`${this.url} freed! @ ${from}`); 24 | } 25 | 26 | public async preload(){ 27 | this.page = await this.browser.newPage(); 28 | await this.page.goto(this.url, { waitUntil: 'networkidle0', timeout: 8000 }).catch((err) => { 29 | console.log(err); 30 | }); 31 | } 32 | 33 | public async verify(){ 34 | let target = await this.page.$("article"); 35 | if (target == null) 36 | return false; 37 | return true; 38 | } 39 | 40 | public async retry(times: number = 0){ 41 | let verified = await this.verify(); 42 | while (!verified && times < 3){ 43 | await this.page.reload({ waitUntil: 'networkidle0', timeout: 8000 }).catch((err) => console.log(err)); 44 | verified = await this.verify(); 45 | times ++; 46 | } 47 | if (!verified || times == 3){ 48 | throw "load failed!" 49 | } 50 | } 51 | 52 | public async setMaxViewPort(){ 53 | let maxViewPort = await this.page.evaluate(() => { 54 | return { 55 | height: Math.max(document.body.scrollHeight, document.body.offsetHeight), 56 | width: Math.max(document.body.scrollWidth, document.body.offsetWidth) 57 | } 58 | }); 59 | await this.page.setViewport(maxViewPort); 60 | } 61 | 62 | public async beforeRun(){ 63 | await this.retry(); 64 | await this.setMaxViewPort(); 65 | } 66 | 67 | public async screenshot(){ 68 | await this.page.bringToFront(); 69 | await this.beforeRun(); 70 | let result = await rawTakeScreenshot(this).then((value) => { 71 | this.free("preparePage.screenshot") 72 | return value; 73 | }); 74 | console.log(`ready to return ${result}`); 75 | return result; 76 | } 77 | 78 | public async translation(translation: string, cssPath: string, tagPath: string){ 79 | await this.page.bringToFront(); 80 | await this.beforeRun(); 81 | let cssStyle = readFileSync(cssPath, { encoding: "utf-8" }); 82 | let tagData = 'data:image/png;base64,' + readFileSync(tagPath, { encoding: "base64" }); 83 | let maxIndex = await addTranslation(this, translation, cssStyle, tagData); 84 | 85 | await this.setMaxViewPort(); 86 | 87 | let result = await rawTakeScreenshot(this, maxIndex, false).then((value) => { 88 | this.free("preparePage.translation") 89 | return value; 90 | }); 91 | 92 | console.log(`ready to return ${result}`); 93 | return result; 94 | } 95 | } -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master 6 | [travis-url]: https://travis-ci.org/nestjs/nest 7 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux 8 | [linux-url]: https://travis-ci.org/nestjs/nest 9 | 10 |

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

11 |

12 | NPM Version 13 | Package License 14 | NPM Downloads 15 | Travis 16 | Linux 17 | Coverage 18 | Gitter 19 | Backers on Open Collective 20 | Sponsors on Open Collective 21 | 22 | 23 |

24 | 26 | 27 | ## Description 28 | 29 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 30 | 31 | ## Installation 32 | 33 | ```bash 34 | $ npm install 35 | ``` 36 | 37 | ## Running the app 38 | 39 | ```bash 40 | # development 41 | $ npm run start 42 | 43 | # watch mode 44 | $ npm run start:dev 45 | 46 | # production mode 47 | $ npm run start:prod 48 | ``` 49 | 50 | ## Test 51 | 52 | ```bash 53 | # unit tests 54 | $ npm run test 55 | 56 | # e2e tests 57 | $ npm run test:e2e 58 | 59 | # test coverage 60 | $ npm run test:cov 61 | ``` 62 | 63 | ## Support 64 | 65 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 66 | 67 | ## Stay in touch 68 | 69 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 70 | - Website - [https://nestjs.com](https://nestjs.com/) 71 | - Twitter - [@nestframework](https://twitter.com/nestframework) 72 | 73 | ## License 74 | 75 | Nest is [MIT licensed](LICENSE). 76 | -------------------------------------------------------------------------------- /bot/addon/listener.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from tweepy import StreamListener 5 | 6 | from .settings import SETTING 7 | from .db_holder import databse 8 | from .tweet_holder import Tweet, tweet_queue 9 | 10 | 11 | class MyListener(StreamListener): 12 | """ 13 | 推文监听流 14 | """ 15 | followed_users = [] 16 | err_list = [] 17 | 18 | def reload_folloed_users(self): 19 | """ 20 | 更新订阅用户列表 21 | """ 22 | self.followed_users = databse.read_all_user_ids() 23 | if SETTING.debug: 24 | print(self.followed_users) 25 | 26 | def get_all_errors(self): 27 | """ 28 | 读取错误列表 29 | """ 30 | errs = self.err_list.copy() 31 | self.err_list.clear() 32 | return errs 33 | 34 | def __init__(self, api=None): 35 | super().__init__(api=api) 36 | 37 | self.reload_folloed_users() 38 | self.err_list = [] 39 | 40 | def on_status(self, status): 41 | if status.user.id_str not in self.followed_users: 42 | # 该流会监听到奇奇怪怪的东西,必须在此过滤掉 43 | return 44 | 45 | screen_name: str = status.user.screen_name 46 | status_id: str = status.id_str 47 | 48 | url = f"https://twitter.com/{screen_name}/status/{status_id}" 49 | 50 | user = databse.get_user(screen_name) 51 | 52 | if user is None: 53 | print(f"WEIRD, {screen_name} is not in database") 54 | return 55 | groups = databse.get_user(screen_name).groups 56 | 57 | tweet_type = "tweet" 58 | 59 | if hasattr(status, "retweeted_status"): 60 | tweet_type = "retweet" 61 | elif status.in_reply_to_user_id_str is not None: 62 | tweet_type = "comment" 63 | 64 | contents = [] 65 | 66 | if hasattr(status, "extended_entities"): 67 | # 推文嵌入的图片被保存在extended_entities项目中 68 | for i in range(min(4, len(status.extended_entities['media']))): 69 | contents.append( 70 | status.extended_entities['media'][i]["media_url_https"]) 71 | 72 | tweet_log_path = f"{SETTING.tweet_log_path}\\{status_id}.json" 73 | # 缓存推文 74 | with open(tweet_log_path, "w", encoding="utf-8") as f: 75 | json.dump({"url": url, "tweet_type": tweet_type, "groups": groups, 76 | "contents": contents}, f, ensure_ascii=False, indent=4) 77 | 78 | curr_tweet = Tweet(url, tweet_type, groups, contents, tweet_log_path) 79 | # 通过队列传送给消费者线程 80 | tweet_queue.put(curr_tweet) 81 | 82 | def on_delete(self, status_id, user_id): 83 | """ 84 | 推文被删除 85 | """ 86 | screen_name = databse.get_user_id(str(user_id)).screen_name 87 | if str(user_id) not in self.followed_users: 88 | return 89 | log_path = f"{SETTING.tweet_log_path}\\{status_id}.json" 90 | if os.path.exists(log_path): 91 | os.remove(log_path) 92 | print(f"{screen_name}发送的{status_id}被删除!") 93 | else: 94 | print(f"{screen_name}发送的{status_id}已被删除!") 95 | 96 | def on_error(self, status_code): 97 | self.err_list.append(f"服务器错误:{status_code}") 98 | 99 | def on_timeout(self): 100 | self.err_list.append(f"连接超时!") 101 | 102 | def on_disconnect(self, notice): 103 | self.keep_alive() 104 | 105 | def on_warning(self, notice): 106 | self.err_list.append(f"服务器警告:{notice}") 107 | 108 | def on_exception(self, exception): 109 | self.err_list.append(f"抛出异常:{exception}") 110 | -------------------------------------------------------------------------------- /server/src/component/Text.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle } from "puppeteer-core"; 2 | import { convert } from "twemoji"; 3 | 4 | 5 | export class TextComponent{ 6 | public element: ElementHandle; 7 | 8 | constructor(element: ElementHandle){ 9 | this.element = element; 10 | } 11 | 12 | public async init(){ 13 | 14 | } 15 | 16 | public toString(): string{ 17 | return ""; 18 | } 19 | } 20 | 21 | 22 | export class Text extends TextComponent{ 23 | public lang: string; 24 | public text: string; 25 | 26 | public async init(){ 27 | this.lang = await this.element.evaluate((element) => { 28 | return element.parentElement.lang; 29 | }, this.element); 30 | this.text = await this.element.evaluate((element) => { 31 | return element.innerHTML.replace(/\n/g, "\\n"); 32 | }, this.element); 33 | } 34 | 35 | public toString(): string{ 36 | return `${this.text}`; 37 | } 38 | } 39 | 40 | 41 | export class Emoji extends TextComponent{ 42 | public emojiCode: string; 43 | public emojiUrl: string; 44 | public emoji: string; 45 | 46 | public async init(){ 47 | this.emojiUrl = await this.element.evaluate((element) => { 48 | return element.querySelector("img").src; 49 | }); 50 | this.emojiCode = this.emojiUrl.substring(this.emojiUrl.lastIndexOf("/") + 1, this.emojiUrl.lastIndexOf(".")); 51 | this.emoji = convert.fromCodePoint(this.emojiCode); 52 | } 53 | 54 | public toString(): string{ 55 | return `${this.emoji}`; 56 | } 57 | } 58 | 59 | 60 | export class Link extends TextComponent{ 61 | public url: string; 62 | 63 | public async init(){ 64 | this.url = await this.element.evaluate((element) => { 65 | return element.parentElement.title; 66 | }, this.element) 67 | } 68 | 69 | public toString(): string{ 70 | return `${this.url}`; 71 | } 72 | } 73 | 74 | export class HashTag extends TextComponent{ 75 | public atName: string; 76 | 77 | public async init(){ 78 | this.atName = await this.element.evaluate((element) => { 79 | return element.querySelector("a").innerText; 80 | }, this.element) 81 | } 82 | 83 | public toString(): string{ 84 | return `@${this.atName}`; 85 | } 86 | } 87 | 88 | 89 | export class TwitterMessage{ 90 | public content: (TextComponent)[] = []; 91 | public element: ElementHandle; 92 | 93 | // pass in article 94 | constructor(element: ElementHandle){ 95 | this.element = element; 96 | } 97 | 98 | public async init(){ 99 | let messageBoard = await this.element.$("div[lang]") 100 | 101 | let childSpans = await messageBoard.$$("span"); 102 | for (let i = 0; i < childSpans.length; i++) { 103 | const currElement = childSpans[i]; 104 | let temp: TextComponent; 105 | 106 | let isEmoji = (await currElement.$("img")) != null; 107 | let isLink = await currElement.evaluate((element) => { 108 | console.log(element); 109 | return element.getAttribute("aria-hidden") == "true"; 110 | }, currElement); 111 | let isHashTag = (await currElement.$("a")) != null; 112 | 113 | if (isEmoji){ 114 | temp = new Emoji(currElement); 115 | }else if (isLink){ 116 | temp = new Link(currElement); 117 | }else if (isHashTag){ 118 | temp = new HashTag(currElement); 119 | }else{ 120 | temp = new Text(currElement); 121 | } 122 | await temp.init(); 123 | this.content.push(temp); 124 | } 125 | } 126 | 127 | public toString(): string{ 128 | let result = ""; 129 | for (let i = 0; i < this.content.length; i++) 130 | result += this.content[i].toString(); 131 | return result; 132 | } 133 | } -------------------------------------------------------------------------------- /server/src/component/Card.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle } from "puppeteer-core"; 2 | import { Text, Emoji, TextComponent } from "./Text"; 3 | 4 | 5 | export class CardComponent{ 6 | public element: ElementHandle; 7 | 8 | constructor(element: ElementHandle){ 9 | this.element = element; 10 | } 11 | 12 | public async init(){ 13 | 14 | } 15 | 16 | public toString(): string{ 17 | return ""; 18 | } 19 | } 20 | 21 | 22 | export class VoteCard extends CardComponent{ 23 | public content: (TextComponent[])[] = []; 24 | 25 | public async init(){ 26 | let choices = await this.element.$$("div[dir='auto'] > span"); 27 | for (let i = 0; i < choices.length; i++) { 28 | const currSpan = choices[i]; 29 | let currChoice: TextComponent[] = []; 30 | let childSpans = await currSpan.$$("span"); 31 | for (let j = 0; j < childSpans.length; j++) { 32 | const subSpan = childSpans[j]; 33 | let isEmoji = (await subSpan.$("img")) != null; 34 | let temp: TextComponent; 35 | if (isEmoji){ 36 | temp = new Emoji(subSpan); 37 | }else{ 38 | temp = new Text(subSpan); 39 | } 40 | await temp.init(); 41 | currChoice.push(temp); 42 | } 43 | this.content.push(currChoice); 44 | } 45 | } 46 | 47 | public toString(): string{ 48 | return `[vote{${this.content.join(';')}}]`; 49 | } 50 | } 51 | 52 | 53 | export class Photo extends CardComponent{ 54 | public url: string; 55 | 56 | // pass in div[testid='tweetPhoto'] 57 | public async init(){ 58 | this.url = await this.element.evaluate((element) => { 59 | let src = element.getAttribute("src"); 60 | return src.substring(0, src.lastIndexOf("&")); 61 | }, this.element); 62 | } 63 | 64 | public toString(): string{ 65 | return `url=${this.url}`; 66 | } 67 | } 68 | 69 | 70 | export class MultiPhoto extends CardComponent{ 71 | public multiplePhotos: Photo[] = []; 72 | 73 | public async init(){ 74 | let photos = await this.element.$$("img"); 75 | for (let i = 0; i < photos.length; i++) { 76 | const currImg = photos[i]; 77 | let currPhoto = new Photo(currImg); 78 | await currPhoto.init(); 79 | this.multiplePhotos.push(currPhoto); 80 | } 81 | } 82 | 83 | public toString(): string{ 84 | return `[img{${this.multiplePhotos.join(';')}}]`; 85 | } 86 | } 87 | 88 | 89 | export class TwitterCard{ 90 | public element: ElementHandle; 91 | public component: CardComponent; 92 | 93 | constructor(element: ElementHandle){ 94 | this.element = element; 95 | } 96 | 97 | public async init(){ 98 | let cardBoard = (await this.element.evaluateHandle((element) => { 99 | let mainBoard = element.querySelector("div[lang]").parentElement.parentElement; 100 | if (mainBoard.childElementCount > 3){ 101 | return mainBoard.childNodes[1]; 102 | }else{ 103 | return null; 104 | } 105 | }, this.element)).asElement(); 106 | if (cardBoard != null){ 107 | 108 | // only need vote and quote 109 | let isVote = (await cardBoard.$("div[data-testid='card.wrapper']")) != null; 110 | let isImage = (await cardBoard.$("div[data-testid='tweetPhoto']")) != null; 111 | 112 | if (isVote){ 113 | this.component = new VoteCard(await this.element.$("div[data-testid='card.wrapper']")); 114 | await this.component.init(); 115 | }else if (isImage){ 116 | this.component = new MultiPhoto(cardBoard); 117 | await this.component.init(); 118 | } 119 | } 120 | } 121 | 122 | public toString(): string{ 123 | if (this.component == null){ 124 | return "[]"; 125 | } 126 | return this.component.toString(); 127 | } 128 | } -------------------------------------------------------------------------------- /server/src/controllers/utils/addTranslation.ts: -------------------------------------------------------------------------------- 1 | import { replace, convert } from "twemoji"; 2 | import { PageHolder } from "./preparePage"; 3 | import { readFileSync } from "fs"; 4 | 5 | 6 | const globalTextMatcher = /{[^}]+}|[^{]+/gm; 7 | const globalTranslationMatcher = /(#\d+ )(?!#\d+ )/gm; 8 | 9 | const globalEmojiStyle = readFileSync(".//src//css//emoji.css", { encoding: "utf-8" }); 10 | 11 | async function preprocess(translation: string){ 12 | // @ts-ignore 13 | return replace(translation, (emoji: string) => { 14 | return "{https://abs-0.twimg.com/emoji/v2/svg/" + convert.toCodePoint(emoji) + ".svg}"; 15 | }) 16 | } 17 | 18 | async function processSingleTranslation(translation: string){ 19 | translation = await preprocess(translation); 20 | 21 | let rawBlocks = translation.match(globalTextMatcher); 22 | for (let i = 0; i < rawBlocks.length; i++){ 23 | let currBlock = rawBlocks[i]; 24 | if (currBlock.startsWith("{")){ 25 | let emojiUrl = currBlock.substring(1, currBlock.length - 1); 26 | rawBlocks[i] = 27 | ` 28 |
29 |
30 | 31 |
32 |
`; 33 | }else{ 34 | rawBlocks[i] = `${currBlock}`; 35 | } 36 | } 37 | return rawBlocks.join(""); 38 | } 39 | 40 | async function solveTranslationBlock(content: string, isComment: boolean){ 41 | let res = {}; 42 | if (isComment){ 43 | if (!content.startsWith("#")){ 44 | res["1"] = await processSingleTranslation(content); 45 | }else{ 46 | let seperated = content.split(globalTranslationMatcher); 47 | for (let i = 1; i < seperated.length; i+= 2){ 48 | let rawIndex = seperated[i]; let rawContent = seperated[i + 1]; 49 | let realIndex = rawIndex.substring(1, rawIndex.length - 1); 50 | let realContent = await processSingleTranslation(rawContent); 51 | res[realIndex] = realContent; 52 | } 53 | } 54 | }else{ 55 | res["1"] = await processSingleTranslation(content); 56 | } 57 | return res; 58 | } 59 | 60 | 61 | export async function addTranslation(pageHolder: PageHolder, translation: string, cssStyle: string, tagData: string){ 62 | const page = pageHolder.page; 63 | let completeCssStyle = globalEmojiStyle + cssStyle; 64 | 65 | let isComment = await page.evaluate(() => { 66 | return document.querySelector('article').firstChild.firstChild.firstChild.childNodes.length < 3; 67 | }) 68 | let processedTranslation = await solveTranslationBlock(translation, isComment); 69 | 70 | let maxIndex = await page.$$eval("article", (elements, trans, css, tag) => { 71 | let styleSheet = document.createElement("style"); 72 | styleSheet.innerHTML = css; 73 | document.body.appendChild(styleSheet); 74 | 75 | let currMax = 0; 76 | for (const key in trans){ 77 | let index = parseInt(key) - 1; 78 | currMax = index > currMax ? index : currMax; 79 | 80 | let currTranslation = trans[key]; 81 | let currArticle = elements[index]; 82 | let textArea = currArticle.querySelector("div[lang]"); 83 | let board = document.createElement("div"); board.className = "wrapper"; 84 | 85 | let isMainComment = currArticle.querySelector("div[data-testid='tweet']").parentElement.childElementCount > 2; 86 | if (isMainComment){ 87 | let tagBoard = document.createElement("div"); tagBoard.className = "wrapper"; 88 | let tagImg = document.createElement("img"); tagImg.className = "tag-img"; tagImg.src = tag; 89 | tagBoard.appendChild(tagImg); textArea.appendChild(tagBoard); 90 | } 91 | 92 | board.innerHTML += currTranslation; 93 | textArea.appendChild(board); 94 | } 95 | return currMax; 96 | }, processedTranslation, completeCssStyle, tagData); 97 | 98 | return maxIndex; 99 | } -------------------------------------------------------------------------------- /bot/addon/group_settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from typing import Dict 5 | 6 | from .settings import SETTING 7 | 8 | 9 | # hard coded group settings 10 | default_group_setting = { 11 | "tweet": True, 12 | "retweet": True, 13 | "comment": True, 14 | "original_text": True, 15 | "translate": True, 16 | "content": True, 17 | "tag_path": f"{SETTING.group_tag_path}\\default_tag.png", 18 | "css_path": f"{SETTING.group_css_path}\\default_text.css" 19 | } 20 | 21 | 22 | class GroupSetting: 23 | """ 24 | 此类包装了群设置,用于缓存 25 | """ 26 | group_id: str 27 | group_setting: dict 28 | group_setting_path: str 29 | 30 | def __init__(self, group_id: str): 31 | self.group_id = group_id 32 | self.group_setting_path = f"{SETTING.group_setting_path}\\{self.group_id}.json" 33 | 34 | if os.path.exists(self.group_setting_path): 35 | # 此群的群设置已经存在,直接读取设置 36 | self.group_setting = self._read_group_setting() 37 | else: 38 | # 此群的群设置尚未初始化,进行初始化生成本地文件 39 | self.group_setting = default_group_setting.copy() 40 | self._write_group_setting() 41 | 42 | if SETTING.debug: 43 | # 调试用 44 | print(f"@ GroupSetting => LOAD {self.group_id} FINISHED") 45 | 46 | def update(self, change: dict): 47 | """ 48 | 更新群设置 49 | :param change: 更新的设置键值对 50 | """ 51 | # 判断是否需要更新本地文件 52 | need_update = False 53 | for k, v in change.items(): 54 | # 获取原本群设置中键对应值,若键错误返回None 55 | previous_setting = self.group_setting.get(k, None) 56 | if previous_setting is not None and v != previous_setting: 57 | # 该键存在且与原来的值不同,更新对应值 58 | self.group_setting[k] = v 59 | need_update = True 60 | if need_update: 61 | # 写入新设置 62 | self._write_group_setting() 63 | 64 | def _read_group_setting(self) -> dict: 65 | """ 66 | 读取群设置 67 | :return: 群设置 68 | """ 69 | with open(self.group_setting_path, "r", encoding="utf-8") as f: 70 | group_setting = json.load(f) 71 | return group_setting 72 | 73 | def _write_group_setting(self): 74 | """ 75 | 写入群设置 76 | """ 77 | with open(self.group_setting_path, "w", encoding="utf-8") as f: 78 | json.dump(self.group_setting, f, ensure_ascii=False, indent=4) 79 | 80 | 81 | class GroupSettingHolder: 82 | """ 83 | 此类保存了所有群设置 84 | """ 85 | # 用字典保存群号对应的群设置 86 | group_settings: Dict[str, GroupSetting] = {} 87 | 88 | def __init__(self): 89 | # 读取群设置目录下所有设置文件 90 | group_setting_filenames = os.listdir(SETTING.group_setting_path) 91 | for filename in group_setting_filenames: 92 | group_id = filename[: filename.rfind(".")] 93 | self.group_settings[group_id] = GroupSetting(group_id) 94 | if SETTING.debug: 95 | print("@ GroupSettingHolder => LOAD FINISHED") 96 | 97 | def update(self, group_id: str, change: dict): 98 | """ 99 | 更新群号对应的群设置 100 | :param group_id: 群号 101 | :param change: 更新的群设置键值对 102 | """ 103 | # 判断该群的群设置是否存在 104 | target_group_setting: GroupSetting = self.group_settings.get( 105 | group_id, None) 106 | if target_group_setting is None: 107 | # 不存在,初始化新群设置 108 | print("none!") 109 | target_group_setting = GroupSetting(group_id) 110 | # 更新设置 111 | target_group_setting.update(change) 112 | self.group_settings[group_id] = target_group_setting 113 | 114 | def get(self, group_id: str) -> dict: 115 | """ 116 | 获取群号对应的群设置 117 | :param group_id: 群号 118 | :return: 群号对应的群设置 119 | """ 120 | target_group_setting: GroupSetting = self.group_settings.get( 121 | group_id, None) 122 | if target_group_setting is None: 123 | # 不存在,初始化新群设置 124 | print("none!") 125 | target_group_setting = GroupSetting(group_id) 126 | if group_id not in self.group_settings: 127 | # 该群设置尚未保存,保存群设置 128 | self.group_settings[group_id] = target_group_setting 129 | return target_group_setting.group_setting 130 | 131 | 132 | # 全局群设置代理 133 | group_setting_holder = GroupSettingHolder() 134 | -------------------------------------------------------------------------------- /bot/addon/db_holder.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import json 3 | 4 | from typing import List, Dict 5 | 6 | from .settings import SETTING 7 | 8 | 9 | class User: 10 | """ 11 | 此类包装了推特用户 12 | """ 13 | screen_name: str 14 | user_id: str 15 | groups: List[str] = [] 16 | 17 | def __init__(self, screen_name: str, user_id: str): 18 | self.screen_name = screen_name 19 | self.user_id = user_id 20 | 21 | def add_group(self, group_id: str): 22 | """ 23 | 为推特用户加入新群 24 | """ 25 | if group_id not in self.groups: 26 | self.groups.append(group_id) 27 | 28 | def load_group(self, raw: str): 29 | """ 30 | 依照sqlite数据库中的条目读取所有群 31 | """ 32 | self.groups = raw.split(",") 33 | # sqlite中条目以","结尾,清除结尾的空字符 34 | self.groups.pop() 35 | for i, group in enumerate(self.groups): 36 | self.groups[i] = str(group) 37 | 38 | def _generate_whole_value(self): 39 | """ 40 | 生成tuple 41 | """ 42 | return (self.screen_name, self.user_id, ",".join(self.groups) + ",") 43 | 44 | def _generate_group_value(self) -> tuple: 45 | """ 46 | 生成群号tuple 47 | """ 48 | return (",".join(self.groups) + ",", self.screen_name) 49 | 50 | 51 | class Database: 52 | """ 53 | 此类保存所有的推特用户 54 | """ 55 | # 用字典保存所有推特用户 56 | users: Dict[str, User] = {} 57 | 58 | def __init__(self): 59 | """ 60 | 从sqlite数据库中读取所有推特用户 61 | """ 62 | db = sqlite3.connect(SETTING.config_path) 63 | cursor = db.cursor() 64 | 65 | raw_db_data = cursor.execute("SELECT * FROM 'users'").fetchall() 66 | for user in raw_db_data: 67 | # screen_name, twitter_id, groups 68 | curr_user = User(user[0], str(user[1])) 69 | curr_user.load_group(user[2]) 70 | self.users[curr_user.screen_name] = curr_user 71 | 72 | cursor.close() 73 | db.close() 74 | 75 | if SETTING.debug: 76 | print("@ Database LOADED") 77 | 78 | def get_user(self, screen_name: str) -> User: 79 | """ 80 | 根据screen name读取用户 81 | :param screen_name: 推特用户的screen_name 82 | :return: 用户对象 83 | """ 84 | return self.users.get(screen_name, None) 85 | 86 | def get_user_id(self, user_id: str) -> User: 87 | """ 88 | 根据user id读取用户 89 | :param user_id: 推特用户的user id 90 | :return: 用户对象 91 | """ 92 | for user in self.users.values(): 93 | if user.user_id == user_id: 94 | return user 95 | return None 96 | 97 | def delete_user(self, screen_name: str, group_id: str): 98 | """ 99 | 删除用户 100 | :param screen_name: 推特用户的screen_name 101 | :param group_id: 群号 102 | """ 103 | previous_user = self.get_user(screen_name) 104 | try: 105 | previous_user.groups.remove(group_id) 106 | except ValueError: 107 | # 群号不存在 108 | return 109 | if len(previous_user.groups) == 0: 110 | # 已经不存在监听该用户的群,删除该用户 111 | self._delete_user(previous_user) 112 | else: 113 | # 移除群 114 | self._update_previous_user(previous_user) 115 | 116 | def add_user(self, screen_name: str, user_id: str, group_id: str): 117 | """ 118 | 增加用户 119 | :param screen_name: 推特用户的screen_name 120 | :param user_id: 推特用户的user id 121 | :param group_id: 群号 122 | """ 123 | previous_user = self.get_user(screen_name) 124 | if previous_user is None: 125 | # 该用户不存在,初始化该用户 126 | curr_user = User(screen_name, user_id) 127 | curr_user.add_group(group_id) 128 | self.users[screen_name] = curr_user 129 | self._update_new_user(curr_user) 130 | else: 131 | # 该用户已存在,更新groups 132 | previous_user.add_group(group_id) 133 | self._update_previous_user(previous_user) 134 | 135 | def read_all_groups(self) -> List[str]: 136 | """ 137 | 读取所有存在监听用户的群 138 | :return: 所有存在监听用户的群 139 | """ 140 | result_groups: List[str] = [] 141 | for user in self.users.values(): 142 | for group in user.groups: 143 | if group not in result_groups: 144 | result_groups.append(group) 145 | return result_groups 146 | 147 | def read_all_user_ids(self) -> List[str]: 148 | """ 149 | 读取所有用户的推特id 150 | :return: 所有用户的推特id 151 | """ 152 | result_ids: List[str] = [] 153 | for user in self.users.values(): 154 | result_ids.append(str(user.user_id)) 155 | return result_ids 156 | 157 | def _update_new_user(self, new_user: User): 158 | """ 159 | 更新sqlite数据库,加入新用户 160 | :param new_user: 用户对象 161 | """ 162 | db = sqlite3.connect(SETTING.config_path) 163 | cursor = db.cursor() 164 | 165 | cursor.execute("INSERT INTO 'users' VALUES (?, ?, ?)", 166 | new_user._generate_whole_value()) 167 | db.commit() 168 | cursor.close() 169 | db.close() 170 | 171 | def _update_previous_user(self, new_user: User): 172 | """ 173 | 更新sqlite数据库,更新已存在的用户 174 | :param new_user: 用户对象 175 | """ 176 | db = sqlite3.connect(SETTING.config_path) 177 | cursor = db.cursor() 178 | 179 | cursor.execute("UPDATE 'users' SET groups = ? WHERE screen_name = ?", 180 | new_user._generate_group_value()) 181 | db.commit() 182 | cursor.close() 183 | db.close() 184 | 185 | def _delete_user(self, user: User): 186 | """ 187 | 更新sqlite数据库,删除已存在的用户 188 | :param user: 用户对象 189 | """ 190 | db = sqlite3.connect(SETTING.config_path) 191 | cursor = db.cursor() 192 | 193 | cursor.execute( 194 | "DELETE FROM 'users' WHERE screen_name = ?", (user.screen_name,)) 195 | db.commit() 196 | cursor.close() 197 | db.close() 198 | 199 | 200 | # 全局用户代理 201 | databse = Database() 202 | -------------------------------------------------------------------------------- /bot/addon/tweet_holder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | import random 5 | import asyncio 6 | import threading 7 | 8 | from queue import Queue 9 | from aiocqhttp import ActionFailed 10 | from nonebot import get_bot, MessageSegment 11 | from typing import List 12 | 13 | 14 | from .group_log import add_group_log 15 | from .group_settings import group_setting_holder 16 | from .server import baidu_translation, take_screenshot 17 | from .settings import SETTING 18 | 19 | tweet_queue = Queue() 20 | bot = get_bot() 21 | 22 | 23 | class Tweet: 24 | """ 25 | 该类包装了推文 26 | """ 27 | url: str 28 | tweet_type: str 29 | tweet_log_path: str 30 | contents: List[str] = [] 31 | groups: List[str] = [] 32 | 33 | def __init__(self, url: str, tweet_type: str, groups: List[str], contents: List[str], tweet_log_path: str): 34 | self.url = url 35 | self.tweet_type = tweet_type 36 | self.groups = groups 37 | self.contents = contents 38 | self.tweet_log_path = tweet_log_path 39 | 40 | 41 | async def send_with_retry(msg: str, group_id: int, time: int = 0): 42 | """ 43 | 递归多次尝试发送,尝试解决-11因为网络问题无法发出图片 44 | :param msg: 需要发出的消息 45 | :param group_id: 群号 46 | :param time: 3-time为重试次数 47 | """ 48 | if time > 2: 49 | return 50 | try: 51 | await bot.send_group_msg(group_id=int(group_id), message=msg) 52 | except ActionFailed as e: 53 | if e.retcode == -11: 54 | await send_with_retry(msg, group_id, time + 1) 55 | else: 56 | print(f"send failed, retcode={e.retcode} @ {group_id}") 57 | return 58 | 59 | 60 | async def send_msg(success_result: dict, tweet: Tweet): 61 | """ 62 | 发送截图成功的消息 63 | :param success_result: 服务端截图正确返回的内容 64 | :param tweet: 当前处理的推文对象 65 | """ 66 | screenshot_path = success_result["msg"] 67 | original_text = success_result["content"] 68 | translated_text = await baidu_translation(original_text) 69 | 70 | screenshot_msg = str(MessageSegment.image(f"file:///{screenshot_path}")) 71 | content_msg = [str(MessageSegment.image(content_url)) 72 | for content_url in tweet.contents] 73 | 74 | for group in tweet.groups: 75 | # 为每个群分别构建消息 76 | group_setting = group_setting_holder.get(group) 77 | if not group_setting.get(tweet.tweet_type, False): 78 | # 当前群没有监听此类消息,跳过 79 | continue 80 | current_msg = "" 81 | if group_setting["original_text"]: 82 | current_msg += f"\n原文:{original_text}\n" 83 | if group_setting["translate"]: 84 | current_msg += f"翻译:{translated_text}\n" 85 | if group_setting["content"]: 86 | current_msg += f"附件:" + "".join(content_msg) 87 | group_log_index = add_group_log(group, tweet.url) 88 | current_msg += f"\n嵌字编号:{group_log_index}" 89 | 90 | # 将服务端替换的换行符替换回来 91 | current_msg = current_msg.replace("\\n", "\n") 92 | # 文字中的emoji不能直接发送,必须进行转码 93 | current_msg = current_msg.encode( 94 | "utf-16", "surrogatepass").decode("utf-16") 95 | 96 | await send_with_retry(screenshot_msg + current_msg, int(group)) 97 | 98 | # TODO: 此方法因为未知原因运行十分缓慢,且有时会长时间无法删除文件,需要优化 99 | print(f"SEND {tweet.url} finished!") 100 | if os.path.exists(tweet.tweet_log_path): 101 | os.remove(tweet.tweet_log_path) 102 | if os.path.exists(screenshot_path): 103 | os.remove(screenshot_path) 104 | 105 | 106 | async def send_fail_msg(failed_result: dict, tweet: Tweet): 107 | """ 108 | 发送截图失败的消息 109 | :param failed_result: 服务端截图失败返回的内容 110 | :param tweet: 当前处理的推文对象 111 | """ 112 | # 读取服务器故障信息,如果服务器没有返回,则为未知错误(不太可能发生 113 | failed_msg = failed_result.get( 114 | "msg", f"unknown error occured on server @ {tweet.url}") 115 | for group in tweet.groups: 116 | await send_with_retry(failed_msg, int(group)) 117 | 118 | 119 | async def send_tweet(tweet: Tweet): 120 | """ 121 | 截图并发送推文 122 | :param tweet: 当前处理的推文对象 123 | """ 124 | screenshot_result: dict = await take_screenshot(tweet.url) 125 | await asyncio.sleep(random.random()) 126 | if screenshot_result.get("status", False): 127 | # succeed 128 | await send_msg(screenshot_result, tweet) 129 | else: 130 | # failed 131 | await send_fail_msg(screenshot_result, tweet) 132 | 133 | 134 | def start_loop(loop: asyncio.AbstractEventLoop): 135 | """ 136 | 在线程中启动asyncio事件循环 137 | """ 138 | asyncio.set_event_loop(loop) 139 | loop.run_forever() 140 | 141 | 142 | class Wrapper(threading.Thread): 143 | """ 144 | 该类包装推文消费者线程 145 | """ 146 | 147 | def load(self): 148 | """ 149 | 读取可能存在的缓存文件 150 | """ 151 | cached_tweet_filenames = os.listdir(SETTING.tweet_log_path) 152 | for tweet_filename in cached_tweet_filenames: 153 | with open(f"{SETTING.tweet_log_path}\\{tweet_filename}", "r", encoding="utf-8") as f: 154 | curr_tweet_raw = json.load(f) 155 | curr_tweet = Tweet(curr_tweet_raw["url"], curr_tweet_raw["tweet_type"], curr_tweet_raw["groups"], 156 | curr_tweet_raw["contents"], f"{SETTING.tweet_log_path}\\{tweet_filename}") 157 | tweet_queue.put(curr_tweet) 158 | if SETTING.debug: 159 | print(f"CACHED TWEET {tweet_filename} LOADED!") 160 | 161 | def run(self): 162 | """ 163 | 运行推文消费者线程 164 | """ 165 | # 初始化新的事件循环 166 | loop = asyncio.new_event_loop() 167 | # 初始化事件循环线程 168 | loop_thread = threading.Thread(target=start_loop, args=(loop,)) 169 | loop_thread.start() 170 | 171 | while True: 172 | print( 173 | "===========================ASKING FOR TWEET===========================") 174 | # 从队列中给拉取推文,如果没有则会阻塞线程 175 | curr_tweet: Tweet = tweet_queue.get() 176 | scheduled_coro = send_tweet(curr_tweet) 177 | # 在事件线程中运行发送函数 178 | asyncio.run_coroutine_threadsafe(scheduled_coro, loop) 179 | print(f"{curr_tweet.url} scheduled!") 180 | # 休息一下 181 | time.sleep(random.randint(1, 3)) 182 | -------------------------------------------------------------------------------- /bot/addon/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | 5 | from aiocqhttp import ActionFailed 6 | from nonebot import on_command, CommandSession, on_request, RequestSession, get_bot, MessageSegment, permission 7 | 8 | from .group_settings import group_setting_holder 9 | from .group_log import read_group_log 10 | from .settings import SETTING 11 | from .db_holder import databse 12 | from .server import add_translation, take_screenshot 13 | 14 | bot = get_bot() 15 | 16 | unknown_error = "unknown error on server, nothing is returned" 17 | 18 | 19 | @on_request('group') 20 | async def answer_req_group(session: RequestSession): 21 | await session.approve() 22 | group_setting_holder.get(str(session.event.group_id)) 23 | 24 | 25 | @on_request('friend') 26 | async def answer_req_friend(session: RequestSession): 27 | await session.approve() 28 | 29 | 30 | def check_is_url(content: str): 31 | return (content.startswith("https://twitter.com") or content.startswith("https://mobile.twitter.com")) 32 | 33 | 34 | @on_command("announce", only_to_me=False, permission=permission.SUPERUSER) 35 | async def announce_command(session: CommandSession): 36 | all_groups = databse.read_all_groups() 37 | for group in all_groups: 38 | try: 39 | await bot.send_group_msg(group_id=int(group), message=session.current_arg_text.strip()) 40 | except ActionFailed as e: 41 | print(f"{e.retcode} @ {group}") 42 | 43 | 44 | @on_command("help", only_to_me=False) 45 | async def help_command(session: CommandSession): 46 | with open("help.json", "r", encoding="utf-8") as f: 47 | help_contents: dict = json.load(f) 48 | args = session.current_arg_text.strip() 49 | # 按照args寻找对应帮助条目,不存在则返回默认条目 50 | content = help_contents.get(args, help_contents["default"]) 51 | session.finish(content) 52 | 53 | 54 | @on_command("enable", only_to_me=False) 55 | async def enable_command(session: CommandSession): 56 | keys = session.current_arg_text.split(";") 57 | # 为每个args初始化值为True 58 | change = dict.fromkeys(keys, True) 59 | try: 60 | group_setting_holder.update(str(session.event.group_id), change) 61 | session.finish("成功") 62 | except Exception as e: 63 | await bot.send_private_msg(user_id=2267980149, message=f"@ {session.event.group_id} => {e}") 64 | session.finish("未知错误,失败") 65 | 66 | 67 | @on_command("disable", only_to_me=False) 68 | async def enable_command(session: CommandSession): 69 | keys = session.current_arg_text.split(";") 70 | # 为每个args初始化值为False 71 | change = dict.fromkeys(keys, False) 72 | try: 73 | group_setting_holder.update(str(session.event.group_id), change) 74 | session.finish("成功") 75 | except Exception as e: 76 | await bot.send_private_msg(user_id=2267980149, message=f"@ {session.event.group_id} => {e}") 77 | session.finish("未知错误,失败") 78 | 79 | 80 | @on_command("tag", only_to_me=False) 81 | async def tag_command(session: CommandSession): 82 | tag_img = session.current_arg_images 83 | if len(tag_img) != 1: 84 | session.finish("请输入一个tag图片!") 85 | buf = requests.get(tag_img[0]) 86 | tag_path = f"{SETTING.group_tag_path}\\{session.event.group_id}_tag.png" 87 | with open(tag_path, "wb") as f: 88 | f.write(buf.content) 89 | group_setting_holder.update( 90 | str(session.event.group_id), {"tag_path": tag_path}) 91 | session.finish("成功") 92 | 93 | 94 | @on_command("css", only_to_me=False) 95 | async def css_command(session: CommandSession): 96 | """ 97 | 设置嵌字时文字span的css样式 98 | """ 99 | css_text = session.current_arg_text.strip() 100 | if not css_text.startswith(".text"): 101 | session.finish("格式错误,请按照css格式定义.text{}!") 102 | css_path = f"{SETTING.group_css_path}\\{session.event.group_id}_text.css" 103 | with open(css_path, "w", encoding="utf-8") as f: 104 | f.write(css_text) 105 | group_setting_holder.update( 106 | str(session.event.group_id), {"css_path": css_path}) 107 | session.finish("成功") 108 | 109 | 110 | @on_command("checkcss", only_to_me=False) 111 | async def check_css_command(session: CommandSession): 112 | """ 113 | 获取当前使用的css样式 114 | """ 115 | css_path = group_setting_holder.get( 116 | str(session.event.group_id))["css_path"] 117 | with open(css_path, "r", encoding="utf-8") as f: 118 | res = f.read() 119 | session.finish(res) 120 | 121 | 122 | @on_command("translate", aliases='tr', only_to_me=False) 123 | async def translate_command(session: CommandSession): 124 | if session.is_first_run: 125 | index = session.current_arg_text.strip() 126 | if not index.isdigit(): 127 | session.finish("请输入嵌字编号!") 128 | session.state["index"] = int(index) - 1 129 | session.get("translation", prompt="请输入翻译") 130 | try: 131 | url = read_group_log(str(session.event.group_id), 132 | session.state["index"]) 133 | group_settings = group_setting_holder.get(str(session.event.group_id)) 134 | result = await add_translation( 135 | url, session.state["translation"], group_settings) 136 | if result.get("status", False): 137 | await session.send(str(MessageSegment.image(f"file:///{result['msg']}"))) 138 | os.remove(result["msg"]) 139 | else: 140 | await session.send(result.get("msg", unknown_error)) 141 | except RuntimeError as e: 142 | session.finish(str(e)) 143 | 144 | 145 | @on_command("screenshot", only_to_me=False) 146 | async def screenshot_command(session: CommandSession): 147 | url = session.current_arg_text.strip() 148 | if check_is_url(url): 149 | session.finish("请输入正确的url") 150 | result = await take_screenshot(url) 151 | if result.get("status", False): 152 | # 文字中的emoji不能直接发送,必须进行转码 153 | text = result['content'].encode( 154 | "utf-16", "surrogatepass").decode("utf-16") 155 | await session.send(str(MessageSegment.image(f"file:///{result['msg']}")) + f"\n原文:{text}") 156 | os.remove(result["msg"]) 157 | else: 158 | await session.send(result.get("msg", unknown_error)) 159 | 160 | 161 | @on_command("freetranslate", aliases='ftr', only_to_me=False) 162 | async def free_translate_command(session: CommandSession): 163 | if session.is_first_run: 164 | url = session.current_arg_text.strip() 165 | if check_is_url(url): 166 | session.finish("请输入正确的url") 167 | session.state["url"] = url 168 | session.get("translation", prompt="请输入翻译") 169 | try: 170 | group_settings = group_setting_holder.get(str(session.event.group_id)) 171 | result = await add_translation( 172 | session.state["url"], session.state["translation"], group_settings) 173 | if result.get("status", False): 174 | await session.send(str(MessageSegment.image(f"file:///{result['msg']}"))) 175 | os.remove(result["msg"]) 176 | else: 177 | await session.send(result.get("msg", unknown_error)) 178 | except RuntimeError as e: 179 | session.finish(str(e)) 180 | --------------------------------------------------------------------------------