Browse Source

Merge branch 'koishi' into fleets-koishi

Mike L 4 years ago
parent
commit
52e7aa0186
16 changed files with 470 additions and 827 deletions
  1. 0 1
      .eslintrc.js
  2. 12 16
      README.md
  3. 4 4
      config.example.json
  4. 0 73
      dist/gifski.js
  5. 159 0
      dist/koishi.js
  6. 8 7
      dist/main.js
  7. 0 218
      dist/mirai.js
  8. 3 18
      dist/twitter.js
  9. 43 71
      dist/webshot.js
  10. 2 4
      package.json
  11. 0 61
      src/gifski.ts
  12. 178 0
      src/koishi.ts
  13. 7 6
      src/main.ts
  14. 0 249
      src/mirai.ts
  15. 15 35
      src/twitter.ts
  16. 39 64
      src/webshot.ts

+ 0 - 1
.eslintrc.js

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

+ 12 - 16
README.md

@@ -1,20 +1,15 @@
-# Mirai Twitter Bot
+# (WIP) GoCQHTTP Twitter Fleets Bot
 
 
 修改自:[rikakomoe/cqhttp-twitter-bot](https://github.com/rikakomoe/cqhttp-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)
 
 
 ## 主要区别
 ## 主要区别
 
 
 - 去除了 Redis
 - 去除了 Redis
-- 支持通过列表 ID 订阅列表,和网页端体验一致
 - 处理订阅链接时大小写不敏感,新订阅链接时先检查是否存在
 - 处理订阅链接时大小写不敏感,新订阅链接时先检查是否存在
-- 支持直接查看指定的推文链接,或(在没有其他用户订阅该用户时)从最新推文回溯到该条推文,由新到旧显示
-- 图片使用 [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 号码必须手动填写
 - 机器人的 QQ 号码必须手动填写
-- Puppeteer 不再自动启动,请手动开启并监听本地 9222 端口(这种方式可以使用 Chrome 或是远程 WebSocket 代理服务器)
 - 自动处理<u>来自群友的好友请求</u>和<u>来自好友的加群邀请</u>
 - 自动处理<u>来自群友的好友请求</u>和<u>来自好友的加群邀请</u>
 
 
 ## 配置
 ## 配置
@@ -23,16 +18,17 @@
 
 
 | 配置项 | 说明 | 默认 |
 | 配置项 | 说明 | 默认 |
 | --- | --- | --- |
 | --- | --- | --- |
-| 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_key | Twitter App consumer_key | (必填) |
 | twitter_consumer_secret |  Twitter App consumer_secret | (必填) |
 | twitter_consumer_secret |  Twitter App consumer_secret | (必填) |
 | twitter_access_token_key | Twitter App access_token_key | (必填) |
 | twitter_access_token_key | Twitter App access_token_key | (必填) |
 | twitter_access_token_secret | Twitter App access_token_secret | (必填) |
 | twitter_access_token_secret | Twitter App access_token_secret | (必填) |
+| twitter_private_auth_token | Twitter 官方 App auth_token | (必填) |
+| twitter_private_csrf_token | Twitter 官方 App csrf_token | (必填) |
 | mode | 工作模式,0 为图文模式,1 为纯文本模式,2 为文<br />本附图模式 | 0 |
 | mode | 工作模式,0 为图文模式,1 为纯文本模式,2 为文<br />本附图模式 | 0 |
-| playwright_ws_spec_endpoint | Playwright 配置拉取 URL,内容格式为:<br />`{<浏览器>:<开发工具 WebSocket 端点 URL>}` | [http://127.0.0.1:8080<br />/playwright-ws.json](http://127.0.0.1:8080/playwright-ws.json)<br />(示例值,模式 0 时必填) |
 | resume_on_start | 是否在启动时从退出时的进度继续(拉取本应用非活<br />动时期错过的推文) | false |
 | resume_on_start | 是否在启动时从退出时的进度继续(拉取本应用非活<br />动时期错过的推文) | false |
 | work_interval | 对单个订阅两次拉取更新的最少间隔时间(秒) | 60 |
 | work_interval | 对单个订阅两次拉取更新的最少间隔时间(秒) | 60 |
 | webshot_delay | 抓取网页截图时等待网页加载的延迟时长(毫秒) | 10000 |
 | webshot_delay | 抓取网页截图时等待网页加载的延迟时长(毫秒) | 10000 |
@@ -43,10 +39,10 @@
 
 
 ## Bug
 ## Bug
 
 
-- 好友消息的图片有可能会失效或直接无法接收(后者会被转换为 `[失败的图片:<地址>]` 格式,然后整条消息会以纯文本模式重发
-- 视频为实验性功能,可能会有各种问题,比如超过大小后会被服务器二压,暂时请酌情自行处理
+- 好友消息的图片有可能会失效或直接无法接收(由于更换后台框架,暂时无法进行进一步处理
+- 视频为实验性功能,可能会有各种问题
 
 
 ## Todo
 ## Todo
 
 
 - 重新实现基于 hash 的文件缓存和转推媒体去重
 - 重新实现基于 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_key": "",
   "twitter_consumer_secret": "",
   "twitter_consumer_secret": "",
   "twitter_access_token_key": "",
   "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();
-    }
-});

+ 159 - 0
dist/koishi.js

@@ -0,0 +1,159 @@
+"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:(image|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 'twitterfleets_view':
+                    case 'twitterfleets_get':
+                        command_1.view(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitterfleets_sub':
+                    case 'twitterfleets_subscribe':
+                        this.botInfo.sub(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitterfleets_unsub':
+                    case 'twitterfleets_unsubscribe':
+                        this.botInfo.unsub(chat, cmdObj.args, reply);
+                        break;
+                    case 'ping':
+                    case 'twitterfleets':
+                        this.botInfo.list(chat, cmdObj.args, reply);
+                        break;
+                    case 'help':
+                        if (cmdObj.args.length === 0) {
+                            reply(`推特故事搬运机器人:
+/twitterfleets - 查询当前聊天中的推特故事订阅
+/twitterfleets_view〈链接〉- 查看该用户当前可见的所有 Fleets
+/twitterfleets_subscribe [链接] - 订阅 Twitter Fleets 搬运
+/twitterfleets_unsubscribe [链接] - 退订 Twitter Fleets 搬运`);
+                        }
+                }
+            }), true);
+        };
+        this.connect = () => __awaiter(this, void 0, void 0, function* () {
+            this.initBot();
+            yield this.app.start();
+            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 exampleConfig = require("../config.example.json");
 const command_1 = require("./command");
 const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const loggers_1 = require("./loggers");
-const mirai_1 = require("./mirai");
+const koishi_1 = require("./koishi");
 const twitter_1 = require("./twitter");
 const twitter_1 = require("./twitter");
 const logger = loggers_1.getLogger();
 const logger = loggers_1.getLogger();
 const sections = [
 const sections = [
@@ -48,9 +48,10 @@ catch (e) {
 }
 }
 const requiredFields = [
 const requiredFields = [
     'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
     'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+    'cq_bot_qq',
 ];
 ];
 const warningFields = [
 const warningFields = [
-    'mirai_http_host', 'mirai_http_port', 'mirai_access_token',
+    'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
 ];
 const optionalFields = [
 const optionalFields = [
     'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start',
     'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start',
@@ -106,11 +107,11 @@ if (!config.resume_on_start) {
         lock.threads[key].offset = '-1';
         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),
     list: (c, a, cb) => command_1.list(c, a, cb, lock),
     sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
     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),
     unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),

+ 0 - 218
dist/mirai.js

@@ -1,218 +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 = this.bot.axios.defaults.maxBodyLength = 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 'twitterfleets_view':
-                    case 'twitterfleets_get':
-                        command_1.view(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitterfleets_sub':
-                    case 'twitterfleets_subscribe':
-                        this.botInfo.sub(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitterfleets_unsub':
-                    case 'twitterfleets_unsubscribe':
-                        this.botInfo.unsub(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'ping':
-                    case 'twitterfleets':
-                        this.botInfo.list(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'help':
-                        if (cmdObj.args.length === 0) {
-                            msg.reply(`推特故事搬运机器人:
-/twitterfleets - 查询当前聊天中的推特故事订阅
-/twitterfleets_view〈链接〉- 查看该用户当前可见的所有 Fleets
-/twitterfleets_subscribe [链接] - 订阅 Twitter Fleets 搬运
-/twitterfleets_unsubscribe [链接] - 退订 Twitter Fleets 搬运`);
-                        }
-                }
-            }));
-        };
-        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

@@ -15,6 +15,7 @@ const path = require("path");
 const request = require("request");
 const request = require("request");
 const Twitter = require("twitter");
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
 const loggers_1 = require("./loggers");
+const koishi_1 = require("./koishi");
 const utils_1 = require("./utils");
 const utils_1 = require("./utils");
 const webshot_1 = require("./webshot");
 const webshot_1 = require("./webshot");
 class ScreenNameNormalizer {
 class ScreenNameNormalizer {
@@ -46,7 +47,6 @@ let sendAllFleets = (username, receiver) => {
 exports.sendAllFleets = sendAllFleets;
 exports.sendAllFleets = sendAllFleets;
 const logger = loggers_1.getLogger('twitter');
 const logger = loggers_1.getLogger('twitter');
 const maxTrials = 3;
 const maxTrials = 3;
-const uploadTimeout = 10000;
 const retryInterval = 1500;
 const retryInterval = 1500;
 const ordinal = (n) => {
 const ordinal = (n) => {
     switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
     switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
@@ -81,25 +81,10 @@ class default_1 {
             ScreenNameNormalizer.savePermaFeedForUser(user);
             ScreenNameNormalizer.savePermaFeedForUser(user);
             return user.screen_name;
             return user.screen_name;
         });
         });
-        this.workOnFleets = (user, fleets, sendFleets) => {
-            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(user, fleets, uploader, sendFleets, this.webshotDelay);
-        };
+        this.workOnFleets = (user, fleets, sendFleets) => this.webshot(user, fleets, sendFleets, this.webshotDelay);
         this.sendFleets = (source, ...to) => (msg, text) => {
         this.sendFleets = (source, ...to) => (msg, text) => {
             to.forEach(subscriber => {
             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) => {
                 retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
                     if (count <= maxTrials) {
                     if (count <= maxTrials) {
                         logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
                         logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);

+ 43 - 71
dist/webshot.js

@@ -1,20 +1,12 @@
 "use strict";
 "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 });
 Object.defineProperty(exports, "__esModule", { value: true });
+const fs_1 = require("fs");
 const axios_1 = require("axios");
 const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
 const CallableInstance = require("callable-instance");
 const html_entities_1 = require("html-entities");
 const html_entities_1 = require("html-entities");
-const gifski_1 = require("./gifski");
+const temp = require("temp");
 const loggers_1 = require("./loggers");
 const loggers_1 = require("./loggers");
-const mirai_1 = require("./mirai");
+const koishi_1 = require("./koishi");
 const xmlEntities = new html_entities_1.XmlEntities();
 const xmlEntities = new html_entities_1.XmlEntities();
 const ZHType = (type) => new class extends String {
 const ZHType = (type) => new class extends String {
     constructor() {
     constructor() {
@@ -32,64 +24,47 @@ const logger = loggers_1.getLogger('webshot');
 class Webshot extends CallableInstance {
 class Webshot extends CallableInstance {
     constructor(_wsUrl, mode, onready) {
     constructor(_wsUrl, mode, onready) {
         super('webshot');
         super('webshot');
-        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: 'stream',
+                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();
                     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 });
+                data.pipe(fs_1.createWriteStream(mediaTempFilePath));
+                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]);
+        });
         this.mode = mode;
         this.mode = mode;
         onready();
         onready();
     }
     }
-    webshot(user, fleets, uploader, callback, webshotDelay) {
+    webshot(user, fleets, callback, webshotDelay) {
         let promise = new Promise(resolve => {
         let promise = new Promise(resolve => {
             resolve();
             resolve();
         });
         });
@@ -98,11 +73,11 @@ class Webshot extends CallableInstance {
             promise = promise.then(() => {
             promise = promise.then(() => {
                 logger.info(`working on ${user.screen_name}/${fleet.fleet_id}`);
                 logger.info(`working on ${user.screen_name}/${fleet.fleet_id}`);
             });
             });
-            const messageChain = [];
+            let messageChain;
             const author = `${user.name} (@${user.screen_name}):\n`;
             const author = `${user.name} (@${user.screen_name}):\n`;
             const date = `${new Date(fleet.created_at)}\n`;
             const date = `${new Date(fleet.created_at)}\n`;
             let text = (_b = author + date + ((_a = fleet.media_bounding_boxes) === null || _a === void 0 ? void 0 : _a.map(box => box.entity.value).join('\n'))) !== null && _b !== void 0 ? _b : '';
             let text = (_b = author + date + ((_a = fleet.media_bounding_boxes) === null || _a === void 0 ? void 0 : _a.map(box => box.entity.value).join('\n'))) !== null && _b !== void 0 ? _b : '';
-            messageChain.push(mirai_1.Message.Plain(author + date));
+            messageChain += author + date;
             if (1 - this.mode % 2)
             if (1 - this.mode % 2)
                 promise = promise.then(() => {
                 promise = promise.then(() => {
                     const media = fleet.media_entity;
                     const media = fleet.media_entity;
@@ -120,16 +95,13 @@ class Webshot extends CallableInstance {
                             .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
                             .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
                             .map(variant => variant.url)[0];
                             .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)
                     return this.fetchMedia(url)
-                        .then(base64url => uploader(mirai_1.Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage))
                         .catch(error => {
                         .catch(error => {
                         logger.warn('unable to fetch media, sending plain text instead...');
                         logger.warn('unable to fetch media, sending plain text instead...');
                         return altMessage;
                         return altMessage;
                     })
                     })
-                        .then(msg => {
-                        messageChain.push(msg);
-                    });
+                        .then(msg => { messageChain += msg; });
                 });
                 });
             promise.then(() => {
             promise.then(() => {
                 logger.info(`done working on ${user.screen_name}/${fleet.fleet_id}, message chain:`);
                 logger.info(`done working on ${user.screen_name}/${fleet.fleet_id}, message chain:`);

+ 2 - 4
package.json

@@ -34,14 +34,12 @@
     "callable-instance": "^2.0.0",
     "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
     "command-line-usage": "^5.0.5",
     "html-entities": "^1.3.1",
     "html-entities": "^1.3.1",
+    "koishi": "^3.10.0",
+    "koishi-adapter-onebot": "^3.0.8",
     "log4js": "^6.3.0",
     "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",
     "read-all-stream": "^3.1.0",
     "request": "^2.72.0",
     "request": "^2.72.0",
     "sha1": "^1.1.1",
     "sha1": "^1.1.1",
-    "sharp": "^0.25.4",
     "temp": "^0.9.1",
     "temp": "^0.9.1",
     "twitter": "^1.7.1",
     "twitter": "^1.7.1",
     "typescript": "^4.2.3"
     "typescript": "^4.2.3"

+ 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();
-  }
-};

+ 178 - 0
src/koishi.ts

@@ -0,0 +1,178 @@
+import { App, Bot, segment, Session } from 'koishi';
+import 'koishi-adapter-onebot';
+
+import { parseCmd, 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:(image|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 'twitterfleets_view':
+        case 'twitterfleets_get':
+          view(chat, cmdObj.args, reply);
+          break;
+        case 'twitterfleets_sub':
+        case 'twitterfleets_subscribe':
+          this.botInfo.sub(chat, cmdObj.args, reply);
+          break;
+        case 'twitterfleets_unsub':
+        case 'twitterfleets_unsubscribe':
+          this.botInfo.unsub(chat, cmdObj.args, reply);
+          break;
+        case 'ping':
+        case 'twitterfleets':
+          this.botInfo.list(chat, cmdObj.args, reply);
+          break;
+        case 'help':
+          if (cmdObj.args.length === 0) {
+            reply(`推特故事搬运机器人:
+/twitterfleets - 查询当前聊天中的推特故事订阅
+/twitterfleets_view〈链接〉- 查看该用户当前可见的所有 Fleets
+/twitterfleets_subscribe [链接] - 订阅 Twitter Fleets 搬运
+/twitterfleets_unsubscribe [链接] - 退订 Twitter Fleets 搬运`);
+          }
+      }
+    }, true);
+  };
+
+  public connect = async () => {
+    this.initBot();
+    await this.app.start();
+    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 * as exampleConfig from '../config.example.json';
 import { list, sub, unsub } from './command';
 import { list, sub, unsub } from './command';
 import { getLogger, setLogLevels } from './loggers';
 import { getLogger, setLogLevels } from './loggers';
-import QQBot from './mirai';
+import QQBot from './koishi';
 import Worker from './twitter';
 import Worker from './twitter';
 
 
 const logger = getLogger();
 const logger = getLogger();
@@ -57,10 +57,11 @@ try {
 
 
 const requiredFields = [
 const requiredFields = [
   'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
   'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+  'cq_bot_qq',
 ];
 ];
 
 
 const warningFields = [
 const warningFields = [
-  'mirai_http_host', 'mirai_http_port', 'mirai_access_token',
+  'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
 ];
 
 
 const optionalFields = [
 const optionalFields = [
@@ -120,10 +121,10 @@ if (!config.resume_on_start) {
 }
 }
 
 
 const qq = new QQBot({
 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),
   list: (c, a, cb) => list(c, a, cb, lock),
   sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
   sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
   unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),
   unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),

+ 0 - 249
src/mirai.ts

@@ -1,249 +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, 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 = this.bot.axios.defaults.maxBodyLength = 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 'twitterfleets_view':
-        case 'twitterfleets_get':
-          view(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitterfleets_sub':
-        case 'twitterfleets_subscribe':
-          this.botInfo.sub(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitterfleets_unsub':
-        case 'twitterfleets_unsubscribe':
-          this.botInfo.unsub(chat, cmdObj.args, msg.reply);
-          break;
-        case 'ping':
-        case 'twitterfleets':
-          this.botInfo.list(chat, cmdObj.args, msg.reply);
-          break;
-        case 'help':
-          if (cmdObj.args.length === 0) {
-            msg.reply(`推特故事搬运机器人:
-/twitterfleets - 查询当前聊天中的推特故事订阅
-/twitterfleets_view〈链接〉- 查看该用户当前可见的所有 Fleets
-/twitterfleets_subscribe [链接] - 订阅 Twitter Fleets 搬运
-/twitterfleets_unsubscribe [链接] - 退订 Twitter Fleets 搬运`);
-          }
-      }
-    });
-  };
-
-  /**
-   * @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;
-  }
-}

+ 15 - 35
src/twitter.ts

@@ -5,7 +5,7 @@ import * as Twitter from 'twitter';
 import TwitterTypes from 'twitter-d';
 import TwitterTypes from 'twitter-d';
 
 
 import { getLogger } from './loggers';
 import { getLogger } from './loggers';
-import QQBot, { Message, MessageChain } from './mirai';
+import QQBot, { Message } from './koishi';
 import { BigNumOps } from './utils';
 import { BigNumOps } from './utils';
 import Webshot from './webshot';
 import Webshot from './webshot';
 
 
@@ -59,7 +59,6 @@ export let sendAllFleets = (username: string, receiver: IChat): void => {
 
 
 const logger = getLogger('twitter');
 const logger = getLogger('twitter');
 const maxTrials = 3;
 const maxTrials = 3;
-const uploadTimeout = 10000;
 const retryInterval = 1500;
 const retryInterval = 1500;
 const ordinal = (n: number) => {
 const ordinal = (n: number) => {
   switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
   switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
@@ -216,44 +215,25 @@ export default class {
   private workOnFleets = (
   private workOnFleets = (
     user: FullUser,
     user: FullUser,
     fleets: Fleets,
     fleets: Fleets,
-    sendFleets: (msg: MessageChain, text: 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) => {
+    sendFleets: (msg: string, text: string) => void
+  ) => this.webshot(user, fleets, sendFleets, this.webshotDelay);
+
+  private sendFleets = (source?: string, ...to: IChat[]) => (msg: string, text: 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) {
           if (count <= maxTrials) {
-            timeout *= (count + 2) / (count + 1);
-            logger.warn(`retry uploading for the ${ordinal(count)} time...`);
+            logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
           } else {
           } else {
-            logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
-            terminate(lastResort());
+            logger.warn(`${count - 1} consecutive failures while sending` +
+            'message chain, trying plain text instead...');
+            terminate(this.bot.sendTo(subscriber, text));
           }
           }
         });
         });
-    };
-    return this.webshot(user, fleets, uploader, sendFleets, this.webshotDelay);
+    });
   };
   };
-
-  private sendFleets = (source?: string, ...to: IChat[]) =>
-    (msg: MessageChain, text: 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` +
-            'message chain, trying plain text instead...');
-              terminate(this.bot.sendTo(subscriber, text));
-            }
-          });
-      });
-    };
   
   
   private getFleets = (userID: string) => new Promise<IFleetFeed | void>((resolve, reject) => {
   private getFleets = (userID: string) => new Promise<IFleetFeed | void>((resolve, reject) => {
     const endpoint = `https://api.twitter.com/fleets/v1/user_fleets?user_id=${userID}`;
     const endpoint = `https://api.twitter.com/fleets/v1/user_fleets?user_id=${userID}`;

+ 39 - 64
src/webshot.ts

@@ -1,10 +1,13 @@
+import { createWriteStream } from 'fs';
+import { Stream } from 'stream';
+
 import axios from 'axios';
 import axios from 'axios';
 import * as CallableInstance from 'callable-instance';
 import * as CallableInstance from 'callable-instance';
 import { XmlEntities } from 'html-entities';
 import { XmlEntities } from 'html-entities';
+import * as temp from 'temp';
 
 
-import gifski from './gifski';
 import { getLogger } from './loggers';
 import { getLogger } from './loggers';
-import { Message, MessageChain } from './mirai';
+import { Message } from './koishi';
 import { Fleets, FullUser, MediaEntity } from './twitter';
 import { Fleets, FullUser, MediaEntity } from './twitter';
 
 
 const xmlEntities = new XmlEntities();
 const xmlEntities = new XmlEntities();
@@ -22,9 +25,7 @@ const typeInZH = {
 
 
 const logger = getLogger('webshot');
 const logger = getLogger('webshot');
 
 
-class Webshot extends CallableInstance<[
-  FullUser, Fleets, (...args) => Promise<any>, (...args) => void, number
-], Promise<void>> {
+class Webshot extends CallableInstance<[FullUser, Fleets, (...args) => void, number], Promise<void>> {
 
 
   private mode: number;
   private mode: number;
 
 
@@ -34,67 +35,46 @@ class Webshot extends CallableInstance<[
     onready();
     onready();
   }
   }
 
 
-  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<Stream>((resolve, reject) => {
+    logger.info(`fetching ${url}`);
+    axios({
+      method: 'get',
+      url,
+      responseType: 'stream',
+      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();
         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});
+      data.pipe(createWriteStream(mediaTempFilePath));
+      const path = `file://${mediaTempFilePath}`;
       switch (ext) {
       switch (ext) {
         case 'jpg':
         case 'jpg':
-          return {mimetype: 'image/jpeg', data};
         case 'png':
         case 'png':
-          return {mimetype: 'image/png', data};
+          return Message.Image(path);
         case 'mp4':
         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])
     })(((/\?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(
   public webshot(
     user: FullUser,
     user: FullUser,
     fleets: Fleets,
     fleets: Fleets,
-    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) => void,
+    callback: (msgs: string, text: string) => void,
     webshotDelay: number
     webshotDelay: number
   ): Promise<void> {
   ): Promise<void> {
     let promise = new Promise<void>(resolve => {
     let promise = new Promise<void>(resolve => {
@@ -104,13 +84,13 @@ class Webshot extends CallableInstance<[
       promise = promise.then(() => {
       promise = promise.then(() => {
         logger.info(`working on ${user.screen_name}/${fleet.fleet_id}`);
         logger.info(`working on ${user.screen_name}/${fleet.fleet_id}`);
       });
       });
-      const messageChain: MessageChain = [];
+      let messageChain: '';
 
 
       // text processing
       // text processing
       const author = `${user.name} (@${user.screen_name}):\n`;
       const author = `${user.name} (@${user.screen_name}):\n`;
       const date = `${new Date(fleet.created_at)}\n`;
       const date = `${new Date(fleet.created_at)}\n`;
       let text = author + date + fleet.media_bounding_boxes?.map(box => box.entity.value as string).join('\n') ?? '';
       let text = author + date + fleet.media_bounding_boxes?.map(box => box.entity.value as string).join('\n') ?? '';
-      messageChain.push(Message.Plain(author + date));
+      messageChain += author + date;
 
 
       // fetch extra entities
       // fetch extra entities
       // tslint:disable-next-line: curly
       // tslint:disable-next-line: curly
@@ -132,18 +112,13 @@ class Webshot extends CallableInstance<[
             .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
             .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
             .map(variant => variant.url)[0]; // largest video
             .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)
         return this.fetchMedia(url)
-          .then(base64url =>
-            uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
-          )
           .catch(error => {
           .catch(error => {
             logger.warn('unable to fetch media, sending plain text instead...');
             logger.warn('unable to fetch media, sending plain text instead...');
             return altMessage;
             return altMessage;
           })
           })
-          .then(msg => {
-            messageChain.push(msg);
-          });
+          .then(msg => { messageChain += msg; });
       });
       });
       promise.then(() => {
       promise.then(() => {
         logger.info(`done working on ${user.screen_name}/${fleet.fleet_id}, message chain:`);
         logger.info(`done working on ${user.screen_name}/${fleet.fleet_id}, message chain:`);