Przeglądaj źródła

Merge branch 'koishi' into mediaonly-koishi

Mike L 4 lat temu
rodzic
commit
0a51e80d6c
20 zmienionych plików z 616 dodań i 1006 usunięć
  1. 0 1
      .eslintrc.js
  2. 11 12
      README.md
  3. 4 4
      config.example.json
  4. 0 73
      dist/gifski.js
  5. 197 0
      dist/koishi.js
  6. 8 7
      dist/main.js
  7. 0 245
      dist/mirai.js
  8. 3 18
      dist/twitter.js
  9. 2 2
      dist/twitter_test.js
  10. 71 121
      dist/webshot.js
  11. 2 12
      dist/webshot_test.js
  12. 2 1
      package.json
  13. 0 61
      src/gifski.ts
  14. 217 0
      src/koishi.ts
  15. 7 6
      src/main.ts
  16. 0 277
      src/mirai.ts
  17. 19 40
      src/twitter.ts
  18. 5 3
      src/twitter_test.js
  19. 63 121
      src/webshot.ts
  20. 5 2
      src/webshot_test.js

+ 0 - 1
.eslintrc.js

@@ -2,7 +2,6 @@
 module.exports = {
   ignorePatterns: [
     '*.js',
-    '!.eslintrc.js',
   ],
   env: {
     browser: true,

+ 11 - 12
README.md

@@ -1,8 +1,8 @@
-# Mirai Twitter Bot
+# (WIP) GoCQHTTP Twitter Bot
 
 修改自:[rikakomoe/cqhttp-twitter-bot](https://github.com/rikakomoe/cqhttp-twitter-bot)
 
-使用 API:[YunYouJun/mirai-ts](https://github.com/YunYouJun/mirai-ts)
+使用 API:[koishijs/koishi](https://github.com/koishijs/koishi)
 
 ## 主要区别
 
@@ -11,10 +11,9 @@
 - 处理订阅链接时大小写不敏感,新订阅链接时先检查是否存在
 - 支持直接查看指定的推文链接,或(在没有其他用户订阅该用户时)从最新推文回溯到该条推文,由新到旧显示
 - 图片使用 [sharp](https://github.com/lovell/sharp) 压缩为 JPEG
-- 视频使用 [gifski](https://github.com/ImageOptim/gifski) 压缩为 GIF(请务必下载并放到 `PATH` 下,推荐[这里](https://github.com/CL-Jeremy/gifski/releases/tag/1.0.1-unofficial)的最新修改版,注意从包管理器安装依赖)
-- 由于推特改版后引发了 Chromium 的 bug,截图部分从 Puppeteer 改为 Playwright 并使用特殊的 WebSocket 格式 URL(这种方式可以使用本地或远程 WebSocket 代理服务器上的 Playwright 服务端)
+- 视频直接作为短视频发送
 - 机器人的 QQ 号码必须手动填写
-- Puppeteer 不再自动启动,请手动开启并监听本地 9222 端口(这种方式可以使用 Chrome 或是远程 WebSocket 代理服务器
+- 由于推特改版后引发了 Chromium 的 bug,截图部分从 Puppeteer 改为 Playwright 并使用特殊的 WebSocket 格式 URL(这种方式可以使用本地或远程 WebSocket 代理服务器上的 Playwright 服务端
 - 自动处理<u>来自群友的好友请求</u>和<u>来自好友的加群邀请</u>
 
 ## 配置
@@ -23,10 +22,10 @@
 
 | 配置项 | 说明 | 默认 |
 | --- | --- | --- |
-| mirai_access_token | Mirai HTTP API authKey(需与插件一致,插件若未<br />配置本项会在 console 显示生成值,请将其填入) | (必填) |
-| mirai_http_host | Mirai HTTP API 插件服务端地址 | 127.0.0.1 |
-| mirai_http_port | Mirai HTTP API 插件服务端口 | 8080 |
-| mirai_bot_qq | Mirai HTTP API 登录的目标机器人 QQ 号 | 10000(示例值,必填) |
+| cq_access_token | OneBot HTTP API access_token(需与插件一致,<br />插件若未配置本项即为空,建议配置为独特值) | ""(空) |
+| cq_ws_host | OneBot HTTP API 插件服务端地址 | 127.0.0.1 |
+| cq_ws_port | OneBot HTTP API 插件服务端口 | 6700 |
+| cq_bot_qq | OneBot HTTP API 登录的目标机器人 QQ 号 | 10000(示例值,必填) |
 | twitter_consumer_key | Twitter App consumer_key | (必填) |
 | twitter_consumer_secret |  Twitter App consumer_secret | (必填) |
 | twitter_access_token_key | Twitter App access_token_key | (必填) |
@@ -43,10 +42,10 @@
 
 ## Bug
 
-- 好友消息的图片有可能会失效或直接无法接收(后者会被转换为 `[失败的图片:<地址>]` 格式,然后整条消息会以纯文本模式重发
-- 视频为实验性功能,可能会有各种问题,比如超过大小后会被服务器二压,暂时请酌情自行处理
+- 好友消息的图片有可能会失效或直接无法接收(由于更换后台框架,暂时无法进行进一步处理
+- 视频为实验性功能,可能会有各种问题
 
 ## Todo
 
 - 重新实现基于 hash 的文件缓存和转推媒体去重
-- 添加选项对时间线进行过滤
+- 添加选项对时间线进行过滤(目前可以在搜索时指定一些条件,但无法用于订阅,具体请使用 `/help` 查看文档)

+ 4 - 4
config.example.json

@@ -1,8 +1,8 @@
 {
-  "mirai_access_token": "",
-  "mirai_http_host": "127.0.0.1",
-  "mirai_http_port": 8080,
-  "mirai_bot_qq": 10000,
+  "cq_access_token": "",
+  "cq_ws_host": "127.0.0.1",
+  "cq_ws_port": 6700,
+  "cq_bot_qq": 10000,
   "twitter_consumer_key": "",
   "twitter_consumer_secret": "",
   "twitter_access_token_key": "",

+ 0 - 73
dist/gifski.js

@@ -1,73 +0,0 @@
-"use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
-    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
-    return new (P || (P = Promise))(function (resolve, reject) {
-        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
-        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
-        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
-        step((generator = generator.apply(thisArg, _arguments || [])).next());
-    });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-const child_process_1 = require("child_process");
-const fs_1 = require("fs");
-const temp = require("temp");
-const loggers_1 = require("./loggers");
-const logger = loggers_1.getLogger('gifski');
-const sizeLimit = 10 * Math.pow(2, 20);
-const roundToEven = (n) => Math.ceil(n / 2) * 2;
-exports.default = (data, targetWidth) => __awaiter(void 0, void 0, void 0, function* () {
-    const outputFilePath = temp.path({ suffix: '.gif' });
-    temp.track();
-    try {
-        const inputFile = temp.openSync();
-        fs_1.writeSync(inputFile.fd, Buffer.from(data));
-        fs_1.closeSync(inputFile.fd);
-        logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
-        const args = [
-            inputFile.path,
-            '-o',
-            outputFilePath,
-            '--fps',
-            '12.5',
-            '--quiet',
-            '--quality',
-            '90',
-        ];
-        if (typeof (targetWidth) === 'number') {
-            args.push('--width', roundToEven(targetWidth).toString());
-        }
-        logger.info(` gifski ${args.join(' ')}`);
-        const gifskiSpawn = child_process_1.spawn('gifski', args);
-        const gifskiResult = new Promise((resolve, reject) => {
-            const sizeChecker = setInterval(() => {
-                if (fs_1.existsSync(outputFilePath) && fs_1.statSync(outputFilePath).size > sizeLimit)
-                    gifskiSpawn.kill();
-            }, 5000);
-            gifskiSpawn.on('exit', () => {
-                clearInterval(sizeChecker);
-                if (!fs_1.existsSync(outputFilePath) || fs_1.statSync(outputFilePath).size === 0)
-                    return reject('no file was created on exit');
-                logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
-                resolve(fs_1.readFileSync(outputFilePath).buffer);
-            });
-        });
-        const stderr = [];
-        gifskiSpawn.stderr.on('data', errdata => stderr.push(errdata));
-        gifskiSpawn.stderr.on('end', () => {
-            if (stderr.length !== 0) {
-                if (!gifskiSpawn.killed)
-                    gifskiSpawn.kill();
-                throw Error(Buffer.concat(stderr).toString());
-            }
-        });
-        return yield gifskiResult;
-    }
-    catch (error) {
-        logger.error(`error converting video to gif ${error ? `message: ${error}` : ''}`);
-        throw Error('error converting video to gif');
-    }
-    finally {
-        temp.cleanup();
-    }
-});

+ 197 - 0
dist/koishi.js

@@ -0,0 +1,197 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+    return new (P || (P = Promise))(function (resolve, reject) {
+        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+        step((generator = generator.apply(thisArg, _arguments || [])).next());
+    });
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Message = void 0;
+const koishi_1 = require("koishi");
+require("koishi-adapter-onebot");
+const command_1 = require("./command");
+const loggers_1 = require("./loggers");
+const utils_1 = require("./utils");
+const logger = loggers_1.getLogger('qqbot');
+const cqUrlFix = (factory) => (...args) => factory(...args).replace(/(?<=\[CQ:.*)url=(?=(base64|file|https?):\/\/)/, 'file=');
+exports.Message = {
+    Image: cqUrlFix(koishi_1.segment.image),
+    Video: cqUrlFix(koishi_1.segment.video),
+    Voice: cqUrlFix(koishi_1.segment.audio),
+    ellipseBase64: (msg) => msg.replace(/(?<=\[CQ:.*base64:\/\/).*?(,|\])/g, '...$1'),
+    separateAttachment: (msg) => {
+        const attachments = [];
+        const message = msg.replace(/\[CQ:(video|record),.*?\]/g, code => {
+            attachments.push(code);
+            return '';
+        });
+        return { message, attachments };
+    },
+};
+class default_1 {
+    constructor(opt) {
+        this.getChat = (session) => __awaiter(this, void 0, void 0, function* () {
+            switch (session.subtype) {
+                case 'private':
+                    if (session.groupId) {
+                        const friendList = yield session.bot.getFriendList();
+                        if (!friendList.some(friendItem => friendItem.userId === session.userId)) {
+                            return {
+                                chatID: {
+                                    qq: Number(session.userId),
+                                    group: Number(session.groupId),
+                                },
+                                chatType: "temp",
+                            };
+                        }
+                    }
+                    return {
+                        chatID: Number(session.userId),
+                        chatType: "private",
+                    };
+                case 'group':
+                    return {
+                        chatID: Number(session.groupId),
+                        chatType: "group",
+                    };
+            }
+        });
+        this.sendTo = (subscriber, messageChain) => utils_1.chainPromises((splitted => [splitted.message, ...splitted.attachments])(exports.Message.separateAttachment(messageChain)).map(msg => {
+            switch (subscriber.chatType) {
+                case 'group':
+                    return this.bot.sendMessage(subscriber.chatID.toString(), msg);
+                case 'private':
+                    return this.bot.sendPrivateMessage(subscriber.chatID.toString(), msg);
+                case 'temp':
+                    return this.bot.sendPrivateMessage(subscriber.chatID.qq.toString(), msg);
+            }
+        }))
+            .then(response => {
+            logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
+            logger.info(response);
+        })
+            .catch(reason => {
+            reason = exports.Message.ellipseBase64(reason);
+            logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
+            throw Error(reason);
+        });
+        this.initBot = () => {
+            this.app = new koishi_1.App({
+                type: 'onebot',
+                server: `ws://${this.botInfo.host}:${this.botInfo.port}`,
+                selfId: this.botInfo.bot_id.toString(),
+                token: this.botInfo.access_token,
+                axiosConfig: {
+                    maxContentLength: Infinity,
+                },
+                processMessage: msg => msg.trim(),
+            });
+            this.app.on('friend-request', (session) => __awaiter(this, void 0, void 0, function* () {
+                const userString = `${session.username}(${session.userId})`;
+                const groupString = `${session.groupName}(${session.groupId})`;
+                logger.debug(`detected new friend request event: ${userString}`);
+                return session.bot.getGroupList().then(groupList => {
+                    if (groupList.some(groupItem => groupItem.groupId === session.groupId)) {
+                        session.bot.handleFriendRequest(session.messageId, true);
+                        return logger.info(`accepted friend request from ${userString} (from group ${groupString})`);
+                    }
+                    logger.warn(`received friend request from ${userString} (from group ${groupString})`);
+                    logger.warn('please manually accept this friend request');
+                });
+            }));
+            this.app.on('group-request', (session) => __awaiter(this, void 0, void 0, function* () {
+                const userString = `${session.username}(${session.userId})`;
+                const groupString = `${session.groupName}(${session.groupId})`;
+                logger.debug(`detected group invitation event: ${groupString}}`);
+                return session.bot.getFriendList().then(friendList => {
+                    if (friendList.some(friendItem => friendItem.userId = session.userId)) {
+                        session.bot.handleGroupRequest(session.messageId, true);
+                        return logger.info(`accepted group invitation from ${userString} (friend)`);
+                    }
+                    logger.warn(`received group invitation from ${userString} (stranger)`);
+                    logger.warn('please manually accept this group invitation');
+                });
+            }));
+            this.app.middleware((session) => __awaiter(this, void 0, void 0, function* () {
+                const chat = yield this.getChat(session);
+                const cmdObj = command_1.parseCmd(session.content);
+                const reply = (msg) => __awaiter(this, void 0, void 0, function* () { return session.send(msg); });
+                switch (cmdObj.cmd) {
+                    case 'twitterpic_view':
+                    case 'twitterpic_get':
+                        command_1.view(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitterpic_query':
+                    case 'twitterpic_gettimeline':
+                        command_1.query(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitterpic_sub':
+                    case 'twitterpic_subscribe':
+                        this.botInfo.sub(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitterpic_unsub':
+                    case 'twitterpic_unsubscribe':
+                        this.botInfo.unsub(chat, cmdObj.args, reply);
+                        break;
+                    case 'ping':
+                    case 'twitterpic':
+                        this.botInfo.list(chat, cmdObj.args, reply);
+                        break;
+                    case 'help':
+                        if (cmdObj.args.length === 0) {
+                            reply(`推特媒体推文搬运机器人:
+/twitterpic - 查询当前聊天中的媒体推文订阅
+/twitterpic_subscribe〈链接|用户名〉- 订阅 Twitter 媒体推文搬运
+/twitterpic_unsubscribe〈链接|用户名〉- 退订 Twitter 媒体推文搬运
+/twitterpic_view〈链接〉- 查看推文(无关是否包含媒体)
+/twitterpic_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitterpic_query)\
+${chat.chatType === "temp" ?
+                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
+                        }
+                        else if (cmdObj.args[0] === 'twitterpic_query') {
+                            reply(`查询时间线中的媒体推文:
+/twitterpic_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
+
+参数列表(方框内全部为可选,留空则为默认):
+    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
+    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
+    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
+    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
+    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
+                                .then(() => reply(`\
+起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
+推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
+count 为正时,从新向旧查询;为负时,从旧向新查询
+count 与 since/until 并用时,取二者中实际查询结果较少者
+例子:/twitterpic_query RiccaTachibana count=5 since="2019-12-30\
+ UTC+9" until="2020-01-06 UTC+8" norts=on
+    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条媒体推文,\
+其中不包含原生转推(实际上用户只发了 1 条)`));
+                        }
+                }
+            }), true);
+        };
+        this.listen = (logMsg = 'connecting to bot provider...') => __awaiter(this, void 0, void 0, function* () {
+            logger.warn(logMsg);
+            try {
+                yield this.app.start();
+            }
+            catch (err) {
+                logger.error(`error connecting to bot provider at ${this.app.options.server}, will retry in 2.5s...`);
+                yield koishi_1.sleep(2500);
+                yield this.listen('retry connecting...');
+            }
+        });
+        this.connect = () => __awaiter(this, void 0, void 0, function* () {
+            this.initBot();
+            yield this.listen();
+            this.bot = this.app.getBot('onebot');
+        });
+        logger.warn(`Initialized koishi on ${opt.host}:${opt.port} with access_token ${opt.access_token}`);
+        this.botInfo = opt;
+    }
+}
+exports.default = default_1;

+ 8 - 7
dist/main.js

@@ -7,7 +7,7 @@ const commandLineUsage = require("command-line-usage");
 const exampleConfig = require("../config.example.json");
 const command_1 = require("./command");
 const loggers_1 = require("./loggers");
-const mirai_1 = require("./mirai");
+const koishi_1 = require("./koishi");
 const twitter_1 = require("./twitter");
 const logger = loggers_1.getLogger();
 const sections = [
@@ -48,9 +48,10 @@ catch (e) {
 }
 const requiredFields = [
     'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+    'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
 ];
 const warningFields = [
-    'mirai_http_host', 'mirai_http_port', 'mirai_access_token',
+    'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
 const optionalFields = [
     'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start',
@@ -106,11 +107,11 @@ if (!config.resume_on_start) {
         lock.threads[key].offset = '-1';
     });
 }
-const qq = new mirai_1.default({
-    access_token: config.mirai_access_token,
-    host: config.mirai_http_host,
-    port: config.mirai_http_port,
-    bot_id: config.mirai_bot_qq,
+const qq = new koishi_1.default({
+    access_token: config.cq_access_token,
+    host: config.cq_ws_host,
+    port: config.cq_ws_port,
+    bot_id: config.cq_bot_qq,
     list: (c, a, cb) => command_1.list(c, a, cb, lock),
     sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
     unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),

+ 0 - 245
dist/mirai.js

@@ -1,245 +0,0 @@
-"use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
-    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
-    return new (P || (P = Promise))(function (resolve, reject) {
-        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
-        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
-        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
-        step((generator = generator.apply(thisArg, _arguments || [])).next());
-    });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.Message = void 0;
-const fs_1 = require("fs");
-const util_1 = require("util");
-const axios_1 = require("axios");
-const mirai_ts_1 = require("mirai-ts");
-const message_1 = require("mirai-ts/dist/message");
-const temp = require("temp");
-const command_1 = require("./command");
-const loggers_1 = require("./loggers");
-const logger = loggers_1.getLogger('qqbot');
-exports.Message = message_1.default;
-class default_1 {
-    constructor(opt) {
-        this.getChat = (msg) => __awaiter(this, void 0, void 0, function* () {
-            switch (msg.type) {
-                case 'FriendMessage':
-                    return {
-                        chatID: msg.sender.id,
-                        chatType: "private",
-                    };
-                case 'GroupMessage':
-                    return {
-                        chatID: msg.sender.group.id,
-                        chatType: "group",
-                    };
-                case 'TempMessage':
-                    const friendList = yield this.bot.api.friendList();
-                    if (friendList.some(friendItem => friendItem.id === msg.sender.id)) {
-                        return {
-                            chatID: msg.sender.id,
-                            chatType: "private",
-                        };
-                    }
-                    return {
-                        chatID: {
-                            qq: msg.sender.id,
-                            group: msg.sender.group.id,
-                        },
-                        chatType: "temp",
-                    };
-            }
-        });
-        this.sendTo = (subscriber, msg) => (() => {
-            switch (subscriber.chatType) {
-                case 'group':
-                    return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
-                case 'private':
-                    return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
-                case 'temp':
-                    return this.bot.api.sendTempMessage(msg, subscriber.chatID.qq, subscriber.chatID.group);
-            }
-        })()
-            .then(response => {
-            logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
-            logger.info(response);
-        })
-            .catch(reason => {
-            logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
-            throw Error(reason);
-        });
-        this.uploadPic = (img, timeout = -1) => {
-            if (timeout)
-                timeout = Math.floor(timeout);
-            if (timeout === 0 || timeout < -1) {
-                return Promise.reject('Error: timeout must be greater than 0ms');
-            }
-            let imgFilePath;
-            if (img.imageId !== '')
-                return Promise.resolve();
-            else if (img.url !== '') {
-                if (img.url.split(':')[0] !== 'data') {
-                    return Promise.reject('Error: URL must be of protocol "data"');
-                }
-                if (img.url.split(',')[0].split(';')[1] !== 'base64') {
-                    return Promise.reject('Error: data URL must be of encoding "base64"');
-                }
-                temp.track();
-                try {
-                    const tempFile = temp.openSync();
-                    fs_1.writeSync(tempFile.fd, Buffer.from(img.url.split(',')[1], 'base64'));
-                    fs_1.closeSync(tempFile.fd);
-                    imgFilePath = tempFile.path;
-                }
-                catch (error) {
-                    logger.error(error);
-                }
-            }
-            else
-                imgFilePath = img.path;
-            try {
-                this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
-                logger.info(`uploading ${JSON.stringify(exports.Message.Image(img.imageId, `${img.url.split(',')[0]},[...]`, img.path))}...`);
-                return this.bot.api.uploadImage('group', fs_1.createReadStream(imgFilePath))
-                    .then(response => {
-                    logger.info(`uploading ${img.path} as group image was successful, response:`);
-                    logger.info(JSON.stringify(response));
-                    img.url = '';
-                    img.path = (response.path).split(/[/\\]/).slice(-1)[0];
-                })
-                    .catch(reason => {
-                    logger.error(`error uploading ${img.path}, reason: ${reason}`);
-                    throw Error(reason);
-                });
-            }
-            finally {
-                temp.cleanup();
-                this.bot.axios.defaults.timeout = 0;
-            }
-        };
-        this.initBot = () => {
-            this.bot = new mirai_ts_1.default({
-                authKey: this.botInfo.access_token,
-                enableWebsocket: false,
-                host: this.botInfo.host,
-                port: this.botInfo.port,
-            });
-            this.bot.axios.defaults.maxContentLength = Infinity;
-            this.bot.on('NewFriendRequestEvent', evt => {
-                logger.debug(`detected new friend request event: ${JSON.stringify(evt)}`);
-                this.bot.api.groupList()
-                    .then((groupList) => {
-                    if (groupList.some(groupItem => groupItem.id === evt.groupId)) {
-                        evt.respond(0);
-                        return logger.info(`accepted friend request from ${evt.fromId} (from group ${evt.groupId})`);
-                    }
-                    logger.warn(`received friend request from ${evt.fromId} (from group ${evt.groupId})`);
-                    logger.warn('please manually accept this friend request');
-                });
-            });
-            this.bot.on('BotInvitedJoinGroupRequestEvent', evt => {
-                logger.debug(`detected group invitation event: ${JSON.stringify(evt)}`);
-                this.bot.api.friendList()
-                    .then((friendList) => {
-                    if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
-                        evt.respond(0);
-                        return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
-                    }
-                    logger.warn(`received group invitation from ${evt.fromId} (unknown)`);
-                    logger.warn('please manually accept this group invitation');
-                });
-            });
-            this.bot.on('message', (msg) => __awaiter(this, void 0, void 0, function* () {
-                const chat = yield this.getChat(msg);
-                const cmdObj = command_1.parseCmd(msg.plain);
-                switch (cmdObj.cmd) {
-                    case 'twitterpic_view':
-                    case 'twitterpic_get':
-                        command_1.view(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitterpic_query':
-                    case 'twitterpic_gettimeline':
-                        command_1.query(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitterpic_sub':
-                    case 'twitterpic_subscribe':
-                        this.botInfo.sub(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitterpic_unsub':
-                    case 'twitterpic_unsubscribe':
-                        this.botInfo.unsub(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'ping':
-                    case 'twitterpic':
-                        this.botInfo.list(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'help':
-                        if (cmdObj.args.length === 0) {
-                            msg.reply(`推特媒体推文搬运机器人:
-/twitterpic - 查询当前聊天中的媒体推文订阅
-/twitterpic_subscribe〈链接|用户名〉- 订阅 Twitter 媒体推文搬运
-/twitterpic_unsubscribe〈链接|用户名〉- 退订 Twitter 媒体推文搬运
-/twitterpic_view〈链接〉- 查看推文(无关是否包含媒体)
-/twitterpic_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitterpic_query)\
-${chat.chatType === "temp" ?
-                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
-                        }
-                        else if (cmdObj.args[0] === 'twitterpic_query') {
-                            msg.reply(`查询时间线中的媒体推文:
-/twitterpic_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
-
-参数列表(方框内全部为可选,留空则为默认):
-    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
-    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
-    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
-    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
-    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
-                                .then(() => msg.reply(`\
-起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
-推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
-count 为正时,从新向旧查询;为负时,从旧向新查询
-count 与 since/until 并用时,取二者中实际查询结果较少者
-例子:/twitterpic_query RiccaTachibana count=5 since="2019-12-30\
- UTC+9" until="2020-01-06 UTC+8" norts=on
-    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条媒体推文,\
-其中不包含原生转推(实际上用户只发了 1 条)`));
-                        }
-                }
-            }));
-        };
-        this.listen = (logMsg) => {
-            if (logMsg !== '') {
-                logger.warn(logMsg !== null && logMsg !== void 0 ? logMsg : 'Listening...');
-            }
-            axios_1.default.get(`http://${this.botInfo.host}:${this.botInfo.port}/about`)
-                .then(() => __awaiter(this, void 0, void 0, function* () {
-                if (logMsg !== '') {
-                    this.bot.listen();
-                    yield this.login();
-                }
-                setTimeout(() => this.listen(''), 5000);
-            }))
-                .catch(() => {
-                logger.error(`Error connecting to bot provider at ${this.botInfo.host}:${this.botInfo.port}`);
-                setTimeout(() => this.listen('Retry listening...'), 2500);
-            });
-        };
-        this.login = (logMsg) => __awaiter(this, void 0, void 0, function* () {
-            logger.warn(logMsg !== null && logMsg !== void 0 ? logMsg : 'Logging in...');
-            yield this.bot.link(this.botInfo.bot_id)
-                .then(() => logger.warn(`Logged in as ${this.botInfo.bot_id}`))
-                .catch(() => {
-                logger.error(`Cannot log in. Do you have a bot logged in as ${this.botInfo.bot_id}?`);
-                return util_1.promisify(setTimeout)(2500).then(() => this.login('Retry logging in...'));
-            });
-        });
-        this.connect = () => {
-            this.initBot();
-            this.listen();
-        };
-        logger.warn(`Initialized mirai-ts for ${opt.host}:${opt.port} with access_token ${opt.access_token}`);
-        this.botInfo = opt;
-    }
-}
-exports.default = default_1;

+ 3 - 18
dist/twitter.js

@@ -14,6 +14,7 @@ const fs = require("fs");
 const path = require("path");
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
+const koishi_1 = require("./koishi");
 const utils_1 = require("./utils");
 const webshot_1 = require("./webshot");
 class ScreenNameNormalizer {
@@ -48,7 +49,6 @@ const snowflake = (epoch) => Number.isNaN(epoch) ? undefined :
     utils_1.BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
 const logger = loggers_1.getLogger('twitter');
 const maxTrials = 3;
-const uploadTimeout = 10000;
 const retryInterval = 1500;
 const ordinal = (n) => {
     switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
@@ -127,22 +127,7 @@ class default_1 {
             });
             return fetchTimeline();
         };
-        this.workOnTweets = (tweets, sendTweets) => {
-            const uploader = (message, lastResort) => {
-                let timeout = uploadTimeout;
-                return retryOnError(() => this.bot.uploadPic(message, timeout).then(() => message), (_, count, terminate) => {
-                    if (count <= maxTrials) {
-                        timeout *= (count + 2) / (count + 1);
-                        logger.warn(`retry uploading for the ${ordinal(count)} time...`);
-                    }
-                    else {
-                        logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
-                        terminate(lastResort());
-                    }
-                });
-            };
-            return this.webshot(tweets, uploader, sendTweets, this.webshotDelay);
-        };
+        this.workOnTweets = (tweets, sendTweets) => this.webshot(tweets, sendTweets, this.webshotDelay);
         this.getTweet = (id, sender) => {
             const endpoint = 'statuses/show';
             const config = {
@@ -157,7 +142,7 @@ class default_1 {
         };
         this.sendTweets = (source, ...to) => (msg, text, author) => {
             to.forEach(subscriber => {
-                logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
+                logger.info(`pushing data${source ? ` of ${koishi_1.Message.ellipseBase64(source)}` : ''} to ${JSON.stringify(subscriber)}`);
                 retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
                     if (count <= maxTrials) {
                         logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);

+ 2 - 2
dist/twitter_test.js

@@ -7,13 +7,13 @@ const configPath = './config.json';
 let worker;
 try {
     const config = require(path.resolve(configPath));
-    worker = new twitter_1.default(Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace('twitter_', ''), v])));
+    worker = new twitter_1.default(Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace('twitter_', '').replace(/_(.)/g, (_, $1) => $1.toUpperCase()), v])));
 }
 catch (e) {
     console.log('Failed to parse config file: ', configPath);
     process.exit(1);
 }
-const webshot = new webshot_1.default(worker.mode, () => {
+const webshot = new webshot_1.default(config.playwright_ws_spec_endpoint, worker.mode, () => {
     worker.webshot = webshot;
     worker.getTweet('1296935552848035840', (msg, text, author) => {
         console.log(author + text);

+ 71 - 121
dist/webshot.js

@@ -1,14 +1,6 @@
 "use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
-    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
-    return new (P || (P = Promise))(function (resolve, reject) {
-        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
-        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
-        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
-        step((generator = generator.apply(thisArg, _arguments || [])).next());
-    });
-};
 Object.defineProperty(exports, "__esModule", { value: true });
+const fs_1 = require("fs");
 const util_1 = require("util");
 const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
@@ -16,9 +8,9 @@ const html_entities_1 = require("html-entities");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("playwright");
 const sharp = require("sharp");
-const gifski_1 = require("./gifski");
+const temp = require("temp");
 const loggers_1 = require("./loggers");
-const mirai_1 = require("./mirai");
+const koishi_1 = require("./koishi");
 const utils_1 = require("./utils");
 const xmlEntities = new html_entities_1.XmlEntities();
 const ZHType = (type) => new class extends String {
@@ -61,9 +53,11 @@ class Webshot extends CallableInstance {
             logger.info('not working on a tweet');
         };
         this.renderWebshot = (url, height, webshotDelay) => {
+            temp.track();
             const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
-            const sharpToBase64 = (pic) => new Promise(resolve => {
-                pic.toBuffer().then(buffer => resolve(`data:image/jpeg;base64,${buffer.toString('base64')}`));
+            const sharpToFile = (pic) => new Promise(resolve => {
+                const webshotTempFilePath = temp.path({ suffix: '.jpg' });
+                pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
             });
             const promise = new Promise((resolve, reject) => {
                 const width = 720;
@@ -86,7 +80,10 @@ class Webshot extends CallableInstance {
                     })
                         .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
                         .then(() => page.addStyleTag({
-                        content: 'header{display:none!important}path[d=\'M20.207 7.043a1 1 0 0 0-1.414 0L12 13.836 5.207 7.043a1 1 0 0 0-1.414 1.414l7.5 7.5a.996.996 0 0 0 1.414 0l7.5-7.5a1 1 0 0 0 0-1.414z\'],div[role=\'button\']{display: none;}',
+                        content: 'header{display:none!important}div[role=\'button\'],[data-testid="tweet"]+*>[class*=" "]+*{display:none}',
+                    }))
+                        .then(() => page.addStyleTag({
+                        content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
                     }))
                         .then(() => page.addStyleTag({
                         content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
@@ -148,7 +145,7 @@ class Webshot extends CallableInstance {
                         }).on('parsed', function () {
                             const idx = (x, y) => (this.width * y + x) << 2;
                             let boundary = null;
-                            let x = zoomFactor * 2;
+                            const x = zoomFactor * 2;
                             for (let y = 0; y < this.height; y += zoomFactor) {
                                 if (this.data[idx(x, y)] !== 255 &&
                                     this.data[idx(x, y)] === this.data[idx(x + zoomFactor * 10, y)]) {
@@ -165,53 +162,20 @@ class Webshot extends CallableInstance {
                                 logger.info(`found boundary at ${boundary}, cropping image`);
                                 this.data = this.data.slice(0, idx(this.width, boundary));
                                 this.height = boundary;
-                                boundary = null;
-                                x = Math.floor(16 * zoomFactor);
-                                let flag = false;
-                                let cnt = 0;
-                                for (let y = this.height - 1 - zoomFactor; y >= 0; y -= zoomFactor) {
-                                    if ((this.data[idx(x, y)] === 255) === flag) {
-                                        cnt++;
-                                        flag = !flag;
-                                    }
-                                    else
-                                        continue;
-                                    if (cnt === 2) {
-                                        boundary = y + 1;
-                                    }
-                                    if (cnt === 4) {
-                                        const b = y + 1;
-                                        if (Math.abs(this.height - boundary - (boundary - b)) <= 3 * zoomFactor) {
-                                            boundary = b;
-                                        }
-                                    }
-                                    if (cnt === 6) {
-                                        const c = y + 1;
-                                        if (Math.abs(this.height - boundary - 2 * (boundary - c)) <= 3 * zoomFactor) {
-                                            boundary = c;
-                                            break;
-                                        }
-                                    }
-                                }
-                                if (boundary !== null) {
-                                    logger.info(`found boundary at ${boundary}, trimming image`);
-                                    this.data = this.data.slice(0, idx(this.width, boundary));
-                                    this.height = boundary;
-                                }
-                                sharpToBase64(jpeg(this.pack())).then(base64 => {
+                                sharpToFile(jpeg(this.pack())).then(path => {
                                     logger.info(`finished webshot for ${url}`);
-                                    resolve({ base64, boundary });
+                                    resolve({ path, boundary });
                                 });
                             }
                             else if (height >= 8 * 1920) {
                                 logger.warn('too large, consider as a bug, returning');
-                                sharpToBase64(jpeg(this.pack())).then(base64 => {
-                                    resolve({ base64, boundary: 0 });
+                                sharpToFile(jpeg(this.pack())).then(path => {
+                                    resolve({ path, boundary: 0 });
                                 });
                             }
                             else {
                                 logger.info('unable to find boundary, try shooting a larger image');
-                                resolve({ base64: '', boundary });
+                                resolve({ path: '', boundary });
                             }
                         }).parse(screenshot);
                     })
@@ -219,7 +183,7 @@ class Webshot extends CallableInstance {
                         if (err instanceof Error && err.name !== 'TimeoutError')
                             throw err;
                         logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-                        resolve({ base64: '', boundary: 0 });
+                        resolve({ path: '', boundary: 0 });
                     })
                         .finally(() => { page.close(); });
                 })
@@ -229,64 +193,47 @@ class Webshot extends CallableInstance {
                 if (data.boundary === null)
                     return this.renderWebshot(url, height + 1920, webshotDelay);
                 else
-                    return data.base64;
+                    return data.path;
             }).catch(error => this.reconnect(error)
                 .then(() => this.renderWebshot(url, height, webshotDelay)));
         };
-        this.fetchMedia = (url) => {
-            const gif = (data) => {
-                const matchDims = /\/(\d+)x(\d+)\//.exec(url);
-                if (matchDims) {
-                    const [width, height] = matchDims.slice(1).map(Number);
-                    const factor = width + height > 1600 ? 0.375 : 0.5;
-                    return gifski_1.default(data, width * factor);
+        this.fetchMedia = (url) => new Promise((resolve, reject) => {
+            logger.info(`fetching ${url}`);
+            axios_1.default({
+                method: 'get',
+                url,
+                responseType: 'arraybuffer',
+                timeout: 150000,
+            }).then(res => {
+                if (res.status === 200) {
+                    logger.info(`successfully fetched ${url}`);
+                    resolve(res.data);
                 }
-                return gifski_1.default(data);
-            };
-            return new Promise((resolve, reject) => {
-                logger.info(`fetching ${url}`);
-                axios_1.default({
-                    method: 'get',
-                    url,
-                    responseType: 'arraybuffer',
-                    timeout: 150000,
-                }).then(res => {
-                    if (res.status === 200) {
-                        logger.info(`successfully fetched ${url}`);
-                        resolve(res.data);
-                    }
-                    else {
-                        logger.error(`failed to fetch ${url}: ${res.status}`);
-                        reject();
-                    }
-                }).catch(err => {
-                    logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
+                else {
+                    logger.error(`failed to fetch ${url}: ${res.status}`);
                     reject();
-                });
-            }).then(data => {
-                var _a;
-                return ((ext) => __awaiter(this, void 0, void 0, function* () {
-                    switch (ext) {
-                        case 'jpg':
-                            return { mimetype: 'image/jpeg', data };
-                        case 'png':
-                            return { mimetype: 'image/png', data };
-                        case 'mp4':
-                            try {
-                                return { mimetype: 'image/gif', data: yield gif(data) };
-                            }
-                            catch (err) {
-                                logger.error(err);
-                                throw Error(err);
-                            }
-                    }
-                }))(((_a = (/\?format=([a-z]+)&/.exec(url))) !== null && _a !== void 0 ? _a : (/.*\/.*\.([^?]+)/.exec(url)))[1])
-                    .catch(() => {
-                    logger.warn('unable to find MIME type of fetched media, failing this fetch');
-                    throw Error();
-                });
-            }).then(typedData => `data:${typedData.mimetype};base64,${Buffer.from(typedData.data).toString('base64')}`);
-        };
+                }
+            }).catch(err => {
+                logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
+                reject();
+            });
+        }).then(data => {
+            var _a;
+            return (ext => {
+                const mediaTempFilePath = temp.path({ suffix: `.${ext}` });
+                fs_1.writeFileSync(mediaTempFilePath, Buffer.from(data));
+                const path = `file://${mediaTempFilePath}`;
+                switch (ext) {
+                    case 'jpg':
+                    case 'png':
+                        return koishi_1.Message.Image(path);
+                    case 'mp4':
+                        return koishi_1.Message.Video(path);
+                }
+                logger.warn('unable to find MIME type of fetched media, failing this fetch');
+                throw Error();
+            })(((_a = (/\?format=([a-z]+)&/.exec(url))) !== null && _a !== void 0 ? _a : (/.*\/.*\.([^?]+)/.exec(url)))[1]);
+        });
         if (this.mode = mode) {
             onready();
         }
@@ -295,7 +242,7 @@ class Webshot extends CallableInstance {
             this.connect(onready);
         }
     }
-    webshot(tweets, uploader, callback, webshotDelay) {
+    webshot(tweets, callback, webshotDelay) {
         let promise = new Promise(resolve => {
             resolve();
         });
@@ -304,7 +251,7 @@ class Webshot extends CallableInstance {
                 logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
             });
             const originTwi = twi.retweeted_status || twi;
-            const messageChain = [];
+            let messageChain = '';
             let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
             if (twi.retweeted_status)
                 author += `RT @${twi.retweeted_status.user.screen_name}: `;
@@ -321,7 +268,7 @@ class Webshot extends CallableInstance {
                     });
                 }
                 if (this.mode > 0)
-                    messageChain.push(mirai_1.Message.Plain(author + xmlEntities.decode(text)));
+                    messageChain += (author + xmlEntities.decode(text));
             });
             if (this.mode === 0) {
                 const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
@@ -333,14 +280,14 @@ class Webshot extends CallableInstance {
                         ] });
                 };
                 promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-                    .then(base64url => {
-                    if (base64url)
-                        return uploader(mirai_1.Message.Image('', base64url, url), () => mirai_1.Message.Plain(author + text));
-                    return mirai_1.Message.Plain(author + text);
+                    .then(fileurl => {
+                    if (fileurl)
+                        return koishi_1.Message.Image(fileurl);
+                    return author + text;
                 })
                     .then(msg => {
                     if (msg)
-                        messageChain.push(msg);
+                        messageChain += msg;
                 });
             }
             if (1 - this.mode % 2)
@@ -357,14 +304,13 @@ class Webshot extends CallableInstance {
                                     .sort((var1, var2) => var2.bitrate - var1.bitrate)
                                     .map(variant => variant.url)[0];
                             }
-                            const altMessage = mirai_1.Message.Plain(`\n[失败的${typeInZH[media.type].type}:${url}]`);
+                            const altMessage = `\n[失败的${typeInZH[media.type].type}:${url}]`;
                             return this.fetchMedia(url)
-                                .then(base64url => uploader(mirai_1.Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage))
                                 .catch(error => {
                                 logger.warn('unable to fetch media, sending plain text instead...');
                                 return altMessage;
                             })
-                                .then(msg => { messageChain.push(msg); });
+                                .then(msg => { messageChain += msg; });
                         }));
                     }
                 });
@@ -375,19 +321,23 @@ class Webshot extends CallableInstance {
                             .filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
                             .map(urlObj => `\n\ud83d\udd17 ${urlObj.expanded_url}`);
                         if (urls.length) {
-                            messageChain.push(mirai_1.Message.Plain(urls.join('')));
+                            messageChain += urls.join('');
                         }
                     });
                 }
             }
             if (originTwi.is_quote_status) {
                 promise = promise.then(() => {
-                    messageChain.push(mirai_1.Message.Plain(`\n回复此命令查看引用的推文:\n/twitter_view ${originTwi.quoted_status_permalink.expanded}`));
+                    var _a, _b;
+                    const match = /\/status\/(\d+)/.exec((_a = originTwi.quoted_status_permalink) === null || _a === void 0 ? void 0 : _a.expanded);
+                    const blockQuoteIdStr = match ? match[1] : (_b = originTwi.quoted_status) === null || _b === void 0 ? void 0 : _b.id_str;
+                    if (blockQuoteIdStr)
+                        messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
                 });
             }
             promise.then(() => {
                 logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-                logger.info(JSON.stringify(messageChain));
+                logger.info(JSON.stringify(koishi_1.Message.ellipseBase64(messageChain)));
                 callback(messageChain, xmlEntities.decode(text), author);
             });
         });

Plik diff jest za duży
+ 2 - 12
dist/webshot_test.js


+ 2 - 1
package.json

@@ -33,8 +33,9 @@
     "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
     "html-entities": "^1.3.1",
+    "koishi": "^3.10.0",
+    "koishi-adapter-onebot": "^3.0.8",
     "log4js": "^6.3.0",
-    "mirai-ts": "github:CL-Jeremy/mirai-ts#upload-file-built",
     "playwright": "^1.9.1",
     "pngjs": "^5.0.0",
     "read-all-stream": "^3.1.0",

+ 0 - 61
src/gifski.ts

@@ -1,61 +0,0 @@
-import { spawn } from 'child_process';
-import { closeSync, existsSync, readFileSync, statSync, writeSync } from 'fs';
-import * as temp from 'temp';
-
-import { getLogger } from './loggers';
-
-const logger = getLogger('gifski');
-
-const sizeLimit = 10 * 2 ** 20;
-const roundToEven = (n: number) => Math.ceil(n / 2) * 2;
-
-export default async (data: ArrayBuffer, targetWidth?: number) => {
-  const outputFilePath = temp.path({suffix: '.gif'});
-  temp.track();
-  try {
-    const inputFile = temp.openSync();
-    writeSync(inputFile.fd, Buffer.from(data));
-    closeSync(inputFile.fd);
-    logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
-    const args = [
-      inputFile.path,
-      '-o',
-      outputFilePath,
-      '--fps',
-      '12.5',
-      '--quiet',
-      '--quality',
-      '90',
-    ];
-    if (typeof(targetWidth) === 'number') {
-      args.push('--width', roundToEven(targetWidth).toString());
-    }
-    logger.info(` gifski ${args.join(' ')}`);
-    const gifskiSpawn = spawn('gifski', args);
-    const gifskiResult = new Promise<ArrayBufferLike>((resolve, reject) => {
-      const sizeChecker = setInterval(() => {
-        if (existsSync(outputFilePath) && statSync(outputFilePath).size > sizeLimit) gifskiSpawn.kill();
-      }, 5000);
-      gifskiSpawn.on('exit', () => {
-        clearInterval(sizeChecker);
-        if (!existsSync(outputFilePath) || statSync(outputFilePath).size === 0) return reject('no file was created on exit');
-        logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
-        resolve(readFileSync(outputFilePath).buffer);
-      });
-    });
-    const stderr = [];
-    gifskiSpawn.stderr.on('data', errdata => stderr.push(errdata));
-    gifskiSpawn.stderr.on('end', () => {
-      if (stderr.length !== 0) {
-        if (!gifskiSpawn.killed) gifskiSpawn.kill();
-        throw Error(Buffer.concat(stderr).toString());
-      }
-    });
-    return await gifskiResult;
-  } catch (error) {
-    logger.error(`error converting video to gif ${error ? `message: ${error}` : ''}`);
-    throw Error('error converting video to gif');
-  } finally {
-    temp.cleanup();
-  }
-};

+ 217 - 0
src/koishi.ts

@@ -0,0 +1,217 @@
+import { App, Bot, segment, Session, sleep } from 'koishi';
+import 'koishi-adapter-onebot';
+
+import { parseCmd, query, view } from './command';
+import { getLogger } from './loggers';
+import { chainPromises } from './utils';
+
+const logger = getLogger('qqbot');
+
+interface IQQProps {
+  access_token: string;
+  host: string;
+  port: number;
+  bot_id: number;
+  list(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
+  sub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
+  unsub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
+}
+
+const cqUrlFix = (factory: segment.Factory<string | ArrayBuffer | Buffer>) =>
+  (...args: Parameters<typeof factory>) => 
+    factory(...args).replace(/(?<=\[CQ:.*)url=(?=(base64|file|https?):\/\/)/, 'file=');
+
+export const Message = {
+  Image: cqUrlFix(segment.image),
+  Video: cqUrlFix(segment.video),
+  Voice: cqUrlFix(segment.audio),
+  ellipseBase64: (msg: string) => msg.replace(/(?<=\[CQ:.*base64:\/\/).*?(,|\])/g, '...$1'),
+  separateAttachment: (msg: string) => {
+    const attachments: string[] = [];
+    const message = msg.replace(/\[CQ:(video|record),.*?\]/g, code => {
+      attachments.push(code);
+      return '';
+    });
+    return {message, attachments};
+  },
+};
+
+export default class {
+
+  private botInfo: IQQProps;
+  private app: App;
+  public bot: Bot;
+
+  private getChat = async (session: Session): Promise<IChat> => {
+    switch (session.subtype) {
+      case 'private':
+        if (session.groupId) { // temp message
+          const friendList = await session.bot.getFriendList();
+          if (!friendList.some(friendItem => friendItem.userId === session.userId)) {
+            return {
+              chatID: {
+                qq: Number(session.userId),
+                group: Number(session.groupId),
+              },
+              chatType: ChatType.Temp,
+            };
+          }
+        }
+        return { // already befriended
+          chatID: Number(session.userId),
+          chatType: ChatType.Private,
+        };
+      case 'group':
+        return {
+          chatID: Number(session.groupId),
+          chatType: ChatType.Group,
+        };
+    }
+  };
+
+  public sendTo = (subscriber: IChat, messageChain: string) => chainPromises(
+    (splitted => [splitted.message, ...splitted.attachments])(
+      Message.separateAttachment(messageChain)
+    ).map(msg => {
+      switch (subscriber.chatType) {
+        case 'group':
+          return this.bot.sendMessage(subscriber.chatID.toString(), msg);
+        case 'private':
+          return this.bot.sendPrivateMessage(subscriber.chatID.toString(), msg);
+        case 'temp': // currently unable to open session, awaiting OneBot v12
+          return this.bot.sendPrivateMessage(subscriber.chatID.qq.toString(), msg);
+      }
+    }))
+    .then(response => {
+      logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
+      logger.info(response);
+    })
+    .catch(reason => {
+      reason = Message.ellipseBase64(reason);
+      logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
+      throw Error(reason);
+    });
+
+  private initBot = () => {
+    this.app = new App({
+      type: 'onebot',
+      server: `ws://${this.botInfo.host}:${this.botInfo.port}`,
+      selfId: this.botInfo.bot_id.toString(),
+      token: this.botInfo.access_token,
+      axiosConfig: {
+        maxContentLength: Infinity,
+      },
+      processMessage: msg => msg.trim(),
+    });
+
+    this.app.on('friend-request', async session => {
+      const userString = `${session.username}(${session.userId})`;
+      const groupString = `${session.groupName}(${session.groupId})`;
+      logger.debug(`detected new friend request event: ${userString}`);
+      return session.bot.getGroupList().then(groupList => {
+        if (groupList.some(groupItem => groupItem.groupId === session.groupId)) {
+          session.bot.handleFriendRequest(session.messageId, true);
+          return logger.info(`accepted friend request from ${userString} (from group ${groupString})`);
+        }
+        logger.warn(`received friend request from ${userString} (from group ${groupString})`);
+        logger.warn('please manually accept this friend request');
+      });
+    });
+
+    this.app.on('group-request', async session => {
+      const userString = `${session.username}(${session.userId})`;
+      const groupString = `${session.groupName}(${session.groupId})`;
+      logger.debug(`detected group invitation event: ${groupString}}`);
+      return session.bot.getFriendList().then(friendList => {
+        if (friendList.some(friendItem => friendItem.userId = session.userId)) {
+          session.bot.handleGroupRequest(session.messageId, true);
+          return logger.info(`accepted group invitation from ${userString} (friend)`);
+        }
+        logger.warn(`received group invitation from ${userString} (stranger)`);
+        logger.warn('please manually accept this group invitation');
+      });
+    });
+
+    this.app.middleware(async session => {
+      const chat = await this.getChat(session);
+      const cmdObj = parseCmd(session.content);
+      const reply = async msg => session.send(msg);
+      switch (cmdObj.cmd) {
+        case 'twitterpic_view':
+        case 'twitterpic_get':
+          view(chat, cmdObj.args, reply);
+          break;
+        case 'twitterpic_query':
+        case 'twitterpic_gettimeline':
+          query(chat, cmdObj.args, reply);
+          break;
+        case 'twitterpic_sub':
+        case 'twitterpic_subscribe':
+          this.botInfo.sub(chat, cmdObj.args, reply);
+          break;
+        case 'twitterpic_unsub':
+        case 'twitterpic_unsubscribe':
+          this.botInfo.unsub(chat, cmdObj.args, reply);
+          break;
+        case 'ping':
+        case 'twitterpic':
+          this.botInfo.list(chat, cmdObj.args, reply);
+          break;
+        case 'help':
+          if (cmdObj.args.length === 0) {
+            reply(`推特媒体推文搬运机器人:
+/twitterpic - 查询当前聊天中的媒体推文订阅
+/twitterpic_subscribe〈链接|用户名〉- 订阅 Twitter 媒体推文搬运
+/twitterpic_unsubscribe〈链接|用户名〉- 退订 Twitter 媒体推文搬运
+/twitterpic_view〈链接〉- 查看推文(无关是否包含媒体)
+/twitterpic_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitterpic_query)\
+${chat.chatType === ChatType.Temp ?
+    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
+}`);
+          } else if (cmdObj.args[0] === 'twitterpic_query') {
+            reply(`查询时间线中的媒体推文:
+/twitterpic_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
+
+参数列表(方框内全部为可选,留空则为默认):
+    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
+    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
+    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
+    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
+    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
+              .then(() => reply(`\
+起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
+推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
+count 为正时,从新向旧查询;为负时,从旧向新查询
+count 与 since/until 并用时,取二者中实际查询结果较少者
+例子:/twitterpic_query RiccaTachibana count=5 since="2019-12-30\
+ UTC+9" until="2020-01-06 UTC+8" norts=on
+    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条媒体推文,\
+其中不包含原生转推(实际上用户只发了 1 条)`)
+              );
+          }
+      }
+    }, true);
+  };
+
+  private listen = async (logMsg = 'connecting to bot provider...'): Promise<void> => {
+    logger.warn(logMsg);
+    try {
+      await this.app.start();
+    } catch (err) {
+      logger.error(`error connecting to bot provider at ${this.app.options.server}, will retry in 2.5s...`);
+      await sleep(2500);
+      await this.listen('retry connecting...');
+    }
+  };
+
+  public connect = async () => {
+    this.initBot();
+    await this.listen();
+    this.bot = this.app.getBot('onebot');
+  };
+
+  constructor(opt: IQQProps) {
+    logger.warn(`Initialized koishi on ${opt.host}:${opt.port} with access_token ${opt.access_token}`);
+    this.botInfo = opt;
+  }
+}

+ 7 - 6
src/main.ts

@@ -8,7 +8,7 @@ import * as commandLineUsage from 'command-line-usage';
 import * as exampleConfig from '../config.example.json';
 import { list, sub, unsub } from './command';
 import { getLogger, setLogLevels } from './loggers';
-import QQBot from './mirai';
+import QQBot from './koishi';
 import Worker from './twitter';
 
 const logger = getLogger();
@@ -57,10 +57,11 @@ try {
 
 const requiredFields = [
   'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+  'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
 ];
 
 const warningFields = [
-  'mirai_http_host', 'mirai_http_port', 'mirai_access_token',
+  'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
 
 const optionalFields = [
@@ -120,10 +121,10 @@ if (!config.resume_on_start) {
 }
 
 const qq = new QQBot({
-  access_token: config.mirai_access_token,
-  host: config.mirai_http_host,
-  port: config.mirai_http_port,
-  bot_id: config.mirai_bot_qq,
+  access_token: config.cq_access_token,
+  host: config.cq_ws_host,
+  port: config.cq_ws_port,
+  bot_id: config.cq_bot_qq,
   list: (c, a, cb) => list(c, a, cb, lock),
   sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
   unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),

+ 0 - 277
src/mirai.ts

@@ -1,277 +0,0 @@
-import { closeSync, createReadStream, writeSync } from 'fs';
-import { promisify } from 'util';
-
-import axios from 'axios';
-import Mirai, { MessageType } from 'mirai-ts';
-import MiraiMessage from 'mirai-ts/dist/message';
-import * as temp from 'temp';
-
-import { parseCmd, query, view } from './command';
-import { getLogger } from './loggers';
-
-const logger = getLogger('qqbot');
-
-interface IQQProps {
-  access_token: string;
-  host: string;
-  port: number;
-  bot_id: number;
-  list(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  sub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  unsub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-}
-
-export type MessageChain = MessageType.MessageChain;
-export const Message = MiraiMessage;
-
-export default class {
-
-  private botInfo: IQQProps;
-  public bot: Mirai;
-
-  private getChat = async (msg: MessageType.ChatMessage): Promise<IChat> => {
-    switch (msg.type) {
-      case 'FriendMessage':
-        return {
-          chatID: msg.sender.id,
-          chatType: ChatType.Private,
-        };
-      case 'GroupMessage':
-        return {
-          chatID: msg.sender.group.id,
-          chatType: ChatType.Group,
-        };
-      case 'TempMessage':
-        const friendList: {
-          id: number,
-          nickname: string,
-          remark: string,
-        }[] = await this.bot.api.friendList();
-        // already befriended
-        if (friendList.some(friendItem => friendItem.id === msg.sender.id)) {
-          return {
-            chatID: msg.sender.id,
-            chatType: ChatType.Private,
-          };
-        }
-        return {
-          chatID: {
-            qq: msg.sender.id,
-            group: msg.sender.group.id,
-          },
-          chatType: ChatType.Temp,
-        };
-    }
-  };
-
-  public sendTo = (subscriber: IChat, msg: string | MessageChain) => (() => {
-    switch (subscriber.chatType) {
-      case 'group':
-        return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
-      case 'private':
-        return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
-        // currently disabled
-      case 'temp':
-        return this.bot.api.sendTempMessage(msg, subscriber.chatID.qq, subscriber.chatID.group);
-    }
-  })()
-    .then(response => {
-      logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
-      logger.info(response);
-    })
-    .catch(reason => {
-      logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
-      throw Error(reason);
-    });
-
-  public uploadPic = (img: MessageType.Image, timeout = -1) => {
-    if (timeout) timeout = Math.floor(timeout);
-    if (timeout === 0 || timeout < -1) {
-      return Promise.reject('Error: timeout must be greater than 0ms');
-    }
-    let imgFilePath: string;
-    if (img.imageId !== '') return Promise.resolve();
-    else if (img.url !== '') {
-      if (img.url.split(':')[0] !== 'data') {
-        return Promise.reject('Error: URL must be of protocol "data"');
-      }
-      if (img.url.split(',')[0].split(';')[1] !== 'base64') {
-        return Promise.reject('Error: data URL must be of encoding "base64"');
-      }
-      temp.track();
-      try {
-        const tempFile = temp.openSync();
-        writeSync(tempFile.fd, Buffer.from(img.url.split(',')[1], 'base64'));
-        closeSync(tempFile.fd);
-        imgFilePath = tempFile.path;
-      } catch (error) {
-        logger.error(error);
-      }
-    } else imgFilePath = img.path;
-    try {
-      this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
-      logger.info(`uploading ${JSON.stringify(
-        Message.Image(img.imageId, `${img.url.split(',')[0]},[...]`, img.path)
-      )}...`);
-      return this.bot.api.uploadImage('group', createReadStream(imgFilePath))
-        .then(response => { // workaround for https://github.com/mamoe/mirai/issues/194
-          logger.info(`uploading ${img.path} as group image was successful, response:`);
-          logger.info(JSON.stringify(response));
-          img.url = '';
-          img.path = (response.path).split(/[/\\]/).slice(-1)[0];
-        })
-        .catch(reason => {
-          logger.error(`error uploading ${img.path}, reason: ${reason}`);
-          throw Error(reason);
-        });
-    } finally {
-      temp.cleanup();
-      this.bot.axios.defaults.timeout = 0;
-    }
-  };
-
-  private initBot = () => {
-    this.bot = new Mirai({
-      authKey: this.botInfo.access_token,
-      enableWebsocket: false,
-      host: this.botInfo.host,
-      port: this.botInfo.port,
-    });
-
-    this.bot.axios.defaults.maxContentLength = Infinity;
-
-    this.bot.on('NewFriendRequestEvent', evt => {
-      logger.debug(`detected new friend request event: ${JSON.stringify(evt)}`);
-      this.bot.api.groupList()
-        .then((groupList: [{
-          id: number,
-          name: string,
-          permission: 'OWNER' | 'ADMINISTRATOR' | 'MEMBER',
-        }]) => {
-          if (groupList.some(groupItem => groupItem.id === evt.groupId)) {
-            evt.respond(0);
-            return logger.info(`accepted friend request from ${evt.fromId} (from group ${evt.groupId})`);
-          }
-          logger.warn(`received friend request from ${evt.fromId} (from group ${evt.groupId})`);
-          logger.warn('please manually accept this friend request');
-        });
-    });
-
-    this.bot.on('BotInvitedJoinGroupRequestEvent', evt => {
-      logger.debug(`detected group invitation event: ${JSON.stringify(evt)}`);
-      this.bot.api.friendList()
-        .then((friendList: [{
-          id: number,
-          nickname: string,
-          remark: string,
-        }]) => {
-          if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
-            evt.respond(0);
-            return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
-          }
-          logger.warn(`received group invitation from ${evt.fromId} (unknown)`);
-          logger.warn('please manually accept this group invitation');
-        });
-    });
-
-    this.bot.on('message', async msg => {
-      const chat = await this.getChat(msg);
-      const cmdObj = parseCmd(msg.plain);
-      switch (cmdObj.cmd) {
-        case 'twitterpic_view':
-        case 'twitterpic_get':
-          view(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitterpic_query':
-        case 'twitterpic_gettimeline':
-          query(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitterpic_sub':
-        case 'twitterpic_subscribe':
-          this.botInfo.sub(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitterpic_unsub':
-        case 'twitterpic_unsubscribe':
-          this.botInfo.unsub(chat, cmdObj.args, msg.reply);
-          break;
-        case 'ping':
-        case 'twitterpic':
-          this.botInfo.list(chat, cmdObj.args, msg.reply);
-          break;
-        case 'help':
-          if (cmdObj.args.length === 0) {
-            msg.reply(`推特媒体推文搬运机器人:
-/twitterpic - 查询当前聊天中的媒体推文订阅
-/twitterpic_subscribe〈链接|用户名〉- 订阅 Twitter 媒体推文搬运
-/twitterpic_unsubscribe〈链接|用户名〉- 退订 Twitter 媒体推文搬运
-/twitterpic_view〈链接〉- 查看推文(无关是否包含媒体)
-/twitterpic_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitterpic_query)\
-${chat.chatType === ChatType.Temp ?
-    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
-}`);
-          } else if (cmdObj.args[0] === 'twitterpic_query') {
-            msg.reply(`查询时间线中的媒体推文:
-/twitterpic_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
-
-参数列表(方框内全部为可选,留空则为默认):
-    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
-    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
-    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
-    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
-    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
-              .then(() => msg.reply(`\
-起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
-推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
-count 为正时,从新向旧查询;为负时,从旧向新查询
-count 与 since/until 并用时,取二者中实际查询结果较少者
-例子:/twitterpic_query RiccaTachibana count=5 since="2019-12-30\
- UTC+9" until="2020-01-06 UTC+8" norts=on
-    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条媒体推文,\
-其中不包含原生转推(实际上用户只发了 1 条)`)
-              );
-          }
-      }
-    });
-  };
-
-  /**
-   * @todo doesn't work if connection is dropped after connection
-   */
-  private listen = (logMsg?: string) => {
-    if (logMsg !== '') {
-      logger.warn(logMsg ?? 'Listening...');
-    }
-    axios.get(`http://${this.botInfo.host}:${this.botInfo.port}/about`)
-      .then(async () => {
-        if (logMsg !== '') {
-          this.bot.listen();
-          await this.login();
-        }
-        setTimeout(() => this.listen(''), 5000);
-      })
-      .catch(() => {
-        logger.error(`Error connecting to bot provider at ${this.botInfo.host}:${this.botInfo.port}`);
-        setTimeout(() => this.listen('Retry listening...'), 2500);
-      });
-  };
-
-  private login = async (logMsg?: string) => {
-    logger.warn(logMsg ?? 'Logging in...');
-    await this.bot.link(this.botInfo.bot_id)
-      .then(() => logger.warn(`Logged in as ${this.botInfo.bot_id}`))
-      .catch(() => {
-        logger.error(`Cannot log in. Do you have a bot logged in as ${this.botInfo.bot_id}?`);
-        return promisify(setTimeout)(2500).then(() => this.login('Retry logging in...'));
-      });
-  };
-
-  public connect = () => {
-    this.initBot();
-    this.listen();
-  };
-
-  constructor(opt: IQQProps) {
-    logger.warn(`Initialized mirai-ts for ${opt.host}:${opt.port} with access_token ${opt.access_token}`);
-    this.botInfo = opt;
-  }
-}

+ 19 - 40
src/twitter.ts

@@ -4,7 +4,7 @@ import * as Twitter from 'twitter';
 import TwitterTypes from 'twitter-d';
 
 import { getLogger } from './loggers';
-import QQBot, { Message, MessageChain } from './mirai';
+import QQBot, { Message } from './koishi';
 import { chainPromises, BigNumOps } from './utils';
 import Webshot from './webshot';
 
@@ -70,7 +70,6 @@ const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
 
 const logger = getLogger('twitter');
 const maxTrials = 3;
-const uploadTimeout = 10000;
 const retryInterval = 1500;
 const ordinal = (n: number) => {
   switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
@@ -263,29 +262,10 @@ export default class {
 
   private workOnTweets = (
     tweets: Tweets,
-    sendTweets: (msg: MessageChain, text: string, author: string) => void
-  ) => {
-    const uploader = (
-      message: ReturnType<typeof Message.Image>,
-      lastResort: (...args) => ReturnType<typeof Message.Plain>
-    ) => {
-      let timeout = uploadTimeout;
-      return retryOnError(() =>
-        this.bot.uploadPic(message, timeout).then(() => message),
-      (_, count, terminate: (defaultValue: ReturnType<typeof Message.Plain>) => void) => {
-        if (count <= maxTrials) {
-          timeout *= (count + 2) / (count + 1);
-          logger.warn(`retry uploading for the ${ordinal(count)} time...`);
-        } else {
-          logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
-          terminate(lastResort());
-        }
-      });
-    };
-    return this.webshot(tweets, uploader, sendTweets, this.webshotDelay);
-  };
+    sendTweets: (msg: string, text: string, author: string) => void
+  ) => this.webshot(tweets, sendTweets, this.webshotDelay);
 
-  public getTweet = (id: string, sender: (msg: MessageChain, text: string, author: string) => void) => {
+  public getTweet = (id: string, sender: (msg: string, text: string, author: string) => void) => {
     const endpoint = 'statuses/show';
     const config = {
       id,
@@ -298,23 +278,22 @@ export default class {
       });
   };
 
-  private sendTweets = (source?: string, ...to: IChat[]) =>
-    (msg: MessageChain, text: string, author: string) => {
-      to.forEach(subscriber => {
-        logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
-        retryOnError(
-          () => this.bot.sendTo(subscriber, msg),
-          (_, count, terminate: (doNothing: Promise<void>) => void) => {
-            if (count <= maxTrials) {
-              logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-            } else {
-              logger.warn(`${count - 1} consecutive failures while sending` +
+  private sendTweets = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
+    to.forEach(subscriber => {
+      logger.info(`pushing data${source ? ` of ${Message.ellipseBase64(source)}` : ''} to ${JSON.stringify(subscriber)}`);
+      retryOnError(
+        () => this.bot.sendTo(subscriber, msg),
+        (_, count, terminate: (doNothing: Promise<void>) => void) => {
+          if (count <= maxTrials) {
+            logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
+          } else {
+            logger.warn(`${count - 1} consecutive failures while sending` +
             'message chain, trying plain text instead...');
-              terminate(this.bot.sendTo(subscriber, author + text));
-            }
-          });
-      });
-    };
+            terminate(this.bot.sendTo(subscriber, author + text));
+          }
+        });
+    });
+  };
 
   public work = () => {
     const lock = this.lock;

+ 5 - 3
src/twitter_test.js

@@ -1,6 +1,6 @@
 import * as path from 'path';
 
-import Worker from './twitter'
+import Worker from './twitter';
 import Webshot from './webshot';
 
 const configPath = './config.json';
@@ -9,13 +9,15 @@ let worker;
 try {
   const config = require(path.resolve(configPath));
   worker = new Worker(
-    Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace('twitter_', ''), v]))
+    Object.fromEntries(Object.entries(config).map(
+      ([k, v]) => [k.replace('twitter_', '').replace(/_(.)/g, (_, $1) => $1.toUpperCase()), v]
+    ))
   );
 } catch (e) {
   console.log('Failed to parse config file: ', configPath);
   process.exit(1);
 }
-const webshot = new Webshot(worker.mode, () => {
+const webshot = new Webshot(config.playwright_ws_spec_endpoint, worker.mode, () => {
   worker.webshot = webshot;
   worker.getTweet('1296935552848035840', (msg, text, author) => {
     console.log(author + text);

+ 63 - 121
src/webshot.ts

@@ -1,3 +1,4 @@
+import { writeFileSync } from 'fs';
 import { Readable } from 'stream';
 import { promisify } from 'util';
 
@@ -7,10 +8,10 @@ import { XmlEntities } from 'html-entities';
 import { PNG } from 'pngjs';
 import * as puppeteer from 'playwright';
 import * as sharp from 'sharp';
+import * as temp from 'temp';
 
-import gifski from './gifski';
 import { getLogger } from './loggers';
-import { Message, MessageChain } from './mirai';
+import { Message } from './koishi';
 import { MediaEntity, Tweets } from './twitter';
 import { chainPromises } from './utils';
 
@@ -29,9 +30,7 @@ const typeInZH = {
 
 const logger = getLogger('webshot');
 
-class Webshot extends CallableInstance<[
-  Tweets, (...args) => Promise<any>, (...args) => void, number
-], Promise<void>> {
+class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Promise<void>> {
 
   private browser: puppeteer.Browser;
   private mode: number;
@@ -76,11 +75,13 @@ class Webshot extends CallableInstance<[
   };
 
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
+    temp.track();
     const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
-    const sharpToBase64 = (pic: sharp.Sharp) => new Promise<string>(resolve => {
-      pic.toBuffer().then(buffer => resolve(`data:image/jpeg;base64,${buffer.toString('base64')}`));
+    const sharpToFile = (pic: sharp.Sharp) => new Promise<string>(resolve => {
+      const webshotTempFilePath = temp.path({suffix: '.jpg'});
+      pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
     });
-    const promise = new Promise<{ base64: string, boundary: null | number }>((resolve, reject) => {
+    const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
       const width = 720;
       const zoomFactor = 2;
       logger.info(`shooting ${width}*${height} webshot for ${url}`);
@@ -102,7 +103,10 @@ class Webshot extends CallableInstance<[
             .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
             // hide header, "more options" button, like and retweet count
             .then(() => page.addStyleTag({
-              content: 'header{display:none!important}path[d=\'M20.207 7.043a1 1 0 0 0-1.414 0L12 13.836 5.207 7.043a1 1 0 0 0-1.414 1.414l7.5 7.5a.996.996 0 0 0 1.414 0l7.5-7.5a1 1 0 0 0 0-1.414z\'],div[role=\'button\']{display: none;}',
+              content: 'header{display:none!important}div[role=\'button\'],[data-testid="tweet"]+*>[class*=" "]+*{display:none}',
+            }))
+            .then(() => page.addStyleTag({
+              content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
             }))
             .then(() => page.addStyleTag({
               content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
@@ -166,7 +170,7 @@ class Webshot extends CallableInstance<[
                 // eslint-disable-next-line @typescript-eslint/no-shadow
                 const idx = (x: number, y: number) => (this.width * y + x) << 2;
                 let boundary: number = null;
-                let x = zoomFactor * 2;
+                const x = zoomFactor * 2;
                 for (let y = 0; y < this.height; y += zoomFactor) {
                   if (
                     this.data[idx(x, y)] !== 255 &&
@@ -186,63 +190,25 @@ class Webshot extends CallableInstance<[
                   this.data = this.data.slice(0, idx(this.width, boundary));
                   this.height = boundary;
 
-                  boundary = null;
-                  x = Math.floor(16 * zoomFactor);
-                  let flag = false;
-                  let cnt = 0;
-                  for (let y = this.height - 1 - zoomFactor; y >= 0; y -= zoomFactor) {
-                    if ((this.data[idx(x, y)] === 255) === flag) {
-                      cnt++;
-                      flag = !flag;
-                    } else continue;
-
-                    // line above the "comment", "retweet", "like", "share" button row
-                    if (cnt === 2) {
-                      boundary = y + 1;
-                    }
-
-                    // if there are a "retweet" count and "like" count row, this will be the line above it
-                    if (cnt === 4) {
-                      const b = y + 1;
-                      if (Math.abs(this.height - boundary - (boundary - b)) <= 3 * zoomFactor) {
-                        boundary = b;
-                      }
-                    }
-
-                    // if "retweet" count and "like" count are two rows, this will be the line above the first
-                    if (cnt === 6) {
-                      const c = y + 1;
-                      if (Math.abs(this.height - boundary - 2 * (boundary - c)) <= 3 * zoomFactor) {
-                        boundary = c;
-                        break;
-                      }
-                    }
-                  }
-                  if (boundary !== null) {
-                    logger.info(`found boundary at ${boundary}, trimming image`);
-                    this.data = this.data.slice(0, idx(this.width, boundary));
-                    this.height = boundary;
-                  }
-
-                  sharpToBase64(jpeg(this.pack())).then(base64 => {
+                  sharpToFile(jpeg(this.pack())).then(path => {
                     logger.info(`finished webshot for ${url}`);
-                    resolve({base64, boundary});
+                    resolve({path, boundary});
                   });
                 } else if (height >= 8 * 1920) {
                   logger.warn('too large, consider as a bug, returning');
-                  sharpToBase64(jpeg(this.pack())).then(base64 => {
-                    resolve({base64, boundary: 0});
+                  sharpToFile(jpeg(this.pack())).then(path => {
+                    resolve({path, boundary: 0});
                   });
                 } else {
                   logger.info('unable to find boundary, try shooting a larger image');
-                  resolve({base64: '', boundary});
+                  resolve({path: '', boundary});
                 }
               }).parse(screenshot);
             })
             .catch(err => {
               if (err instanceof Error && err.name !== 'TimeoutError') throw err;
               logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-              resolve({base64: '', boundary: 0});
+              resolve({path: '', boundary: 0});
             })
             .finally(() => { page.close(); });
         })
@@ -250,72 +216,51 @@ class Webshot extends CallableInstance<[
     });
     return promise.then(data => {
       if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
-      else return data.base64;
+      else return data.path;
     }).catch(error => this.reconnect(error)
       .then(() => this.renderWebshot(url, height, webshotDelay))
     );
   };
 
-  private fetchMedia = (url: string): Promise<string> => {
-    const gif = (data: ArrayBuffer) => {
-      const matchDims = /\/(\d+)x(\d+)\//.exec(url);
-      if (matchDims) {
-        const [ width, height ] = matchDims.slice(1).map(Number);
-        const factor = width + height > 1600 ? 0.375 : 0.5;
-        return gifski(data, width * factor);
-      }
-      return gifski(data);
-    };
-
-    return new Promise<ArrayBuffer>((resolve, reject) => {
-      logger.info(`fetching ${url}`);
-      axios({
-        method: 'get',
-        url,
-        responseType: 'arraybuffer',
-        timeout: 150000,
-      }).then(res => {
-        if (res.status === 200) {
-          logger.info(`successfully fetched ${url}`);
-          resolve(res.data);
-        } else {
-          logger.error(`failed to fetch ${url}: ${res.status}`);
-          reject();
-        }
-      }).catch (err => {
-        logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
+  private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
+    logger.info(`fetching ${url}`);
+    axios({
+      method: 'get',
+      url,
+      responseType: 'arraybuffer',
+      timeout: 150000,
+    }).then(res => {
+      if (res.status === 200) {
+        logger.info(`successfully fetched ${url}`);
+        resolve(res.data);
+      } else {
+        logger.error(`failed to fetch ${url}: ${res.status}`);
         reject();
-      });
-    }).then(data => (async ext => {
+      }
+    }).catch (err => {
+      logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
+      reject();
+    });
+  }).then(data =>
+    (ext => {
+      const mediaTempFilePath = temp.path({suffix: `.${ext}`});
+      writeFileSync(mediaTempFilePath, Buffer.from(data));
+      const path = `file://${mediaTempFilePath}`;
       switch (ext) {
         case 'jpg':
-          return {mimetype: 'image/jpeg', data};
         case 'png':
-          return {mimetype: 'image/png', data};
+          return Message.Image(path);
         case 'mp4':
-          try {
-            return {mimetype: 'image/gif', data: await gif(data)};
-          } catch (err) {
-            logger.error(err);
-            throw Error(err);
-          }
+          return Message.Video(path);
       }
+      logger.warn('unable to find MIME type of fetched media, failing this fetch');
+      throw Error();
     })(((/\?format=([a-z]+)&/.exec(url)) ?? (/.*\/.*\.([^?]+)/.exec(url)))[1])
-      .catch(() => {
-        logger.warn('unable to find MIME type of fetched media, failing this fetch');
-        throw Error();
-      })
-    ).then(typedData => `data:${typedData.mimetype};base64,${Buffer.from(typedData.data).toString('base64')}`
-    );
-  };
+  );
 
   public webshot(
     tweets: Tweets,
-    uploader: (
-      img: ReturnType<typeof Message.Image>,
-      lastResort: (...args) => ReturnType<typeof Message.Plain>
-    ) => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
-    callback: (msgs: MessageChain, text: string, author: string) => void,
+    callback: (msgs: string, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
     let promise = new Promise<void>(resolve => {
@@ -326,7 +271,7 @@ class Webshot extends CallableInstance<[
         logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
       });
       const originTwi = twi.retweeted_status || twi;
-      const messageChain: MessageChain = [];
+      let messageChain = '';
 
       // text processing
       let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
@@ -345,7 +290,7 @@ class Webshot extends CallableInstance<[
             text = text.replace(new RegExp(media.url, 'gm'), this.mode === 1 ? typeInZH[media.type] : '');
           });
         }
-        if (this.mode > 0) messageChain.push(Message.Plain(author + xmlEntities.decode(text)));
+        if (this.mode > 0) messageChain += (author + xmlEntities.decode(text));
       });
 
       // invoke webshot
@@ -361,12 +306,12 @@ class Webshot extends CallableInstance<[
           };
         };
         promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-          .then(base64url => {
-            if (base64url) return uploader(Message.Image('', base64url, url), () => Message.Plain(author + text));
-            return Message.Plain(author + text);
+          .then(fileurl => {
+            if (fileurl) return Message.Image(fileurl);
+            return author + text;
           })
           .then(msg => {
-            if (msg) messageChain.push(msg);
+            if (msg) messageChain += msg;
           });
       }
       // fetch extra entities
@@ -384,16 +329,13 @@ class Webshot extends CallableInstance<[
                 .sort((var1, var2) => var2.bitrate - var1.bitrate)
                 .map(variant => variant.url)[0]; // largest video
             }
-            const altMessage = Message.Plain(`\n[失败的${typeInZH[media.type as keyof typeof typeInZH].type}:${url}]`);
+            const altMessage = `\n[失败的${typeInZH[media.type as keyof typeof typeInZH].type}:${url}]`;
             return this.fetchMedia(url)
-              .then(base64url =>
-                uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
-              )
               .catch(error => {
                 logger.warn('unable to fetch media, sending plain text instead...');
                 return altMessage;
               })
-              .then(msg => { messageChain.push(msg); });
+              .then(msg => { messageChain += msg; });
           }));
         }
       });
@@ -405,7 +347,7 @@ class Webshot extends CallableInstance<[
               .filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
               .map(urlObj => `\n\ud83d\udd17 ${urlObj.expanded_url}`);
             if (urls.length) {
-              messageChain.push(Message.Plain(urls.join('')));
+              messageChain += urls.join('');
             }
           });
         }
@@ -413,14 +355,14 @@ class Webshot extends CallableInstance<[
       // refer to quoted tweet, if any
       if (originTwi.is_quote_status) {
         promise = promise.then(() => {
-          messageChain.push(
-            Message.Plain(`\n回复此命令查看引用的推文:\n/twitter_view ${originTwi.quoted_status_permalink.expanded}`)
-          );
+          const match = /\/status\/(\d+)/.exec(originTwi.quoted_status_permalink?.expanded);
+          const blockQuoteIdStr = match ? match[1] : originTwi.quoted_status?.id_str;
+          if (blockQuoteIdStr) messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
         });
       }
       promise.then(() => {
         logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-        logger.info(JSON.stringify(messageChain));
+        logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
         callback(messageChain, xmlEntities.decode(text), author);
       });
     });

Plik diff jest za duży
+ 5 - 2
src/webshot_test.js


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików