Browse Source

initial migration to Koishi.js

Mike L 3 years ago
parent
commit
50d7158323
15 changed files with 463 additions and 729 deletions
  1. 0 1
      .eslintrc.js
  2. 174 0
      dist/koishi.js
  3. 2 2
      dist/main.js
  4. 0 245
      dist/mirai.js
  5. 1 17
      dist/twitter.js
  6. 46 76
      dist/webshot.js
  7. 2 11
      dist/webshot_test.js
  8. 2 1
      package.json
  9. 187 0
      src/koishi.ts
  10. 1 1
      src/main.ts
  11. 0 277
      src/mirai.ts
  12. 5 24
      src/twitter.ts
  13. 1 1
      src/twitter_test.js
  14. 40 71
      src/webshot.ts
  15. 2 2
      src/webshot_test.js

+ 0 - 1
.eslintrc.js

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

+ 174 - 0
dist/koishi.js

@@ -0,0 +1,174 @@
+"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.ellipseBase64InMessage = exports.message = void 0;
+const koishi_1 = require("koishi");
+require("koishi-adapter-onebot");
+const command_1 = require("./command");
+const loggers_1 = require("./loggers");
+const logger = loggers_1.getLogger('qqbot');
+exports.message = koishi_1.segment;
+const ellipseBase64InMessage = (msg) => msg.replace(/(?<=\[CQ:.*base64:\/\/).*?(,|\])/, '...$1');
+exports.ellipseBase64InMessage = ellipseBase64InMessage;
+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, 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 => {
+            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: `http://${this.botInfo.host}:${this.botInfo.port}`,
+                selfId: this.botInfo.bot_id.toString(),
+                token: this.botInfo.access_token,
+                onebot: {
+                    quickOperation: 100,
+                },
+                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 'twitter_view':
+                    case 'twitter_get':
+                        command_1.view(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitter_query':
+                    case 'twitter_gettimeline':
+                        command_1.query(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitter_sub':
+                    case 'twitter_subscribe':
+                        this.botInfo.sub(chat, cmdObj.args, reply);
+                        break;
+                    case 'twitter_unsub':
+                    case 'twitter_unsubscribe':
+                        this.botInfo.unsub(chat, cmdObj.args, reply);
+                        break;
+                    case 'ping':
+                    case 'twitter':
+                        this.botInfo.list(chat, cmdObj.args, reply);
+                        break;
+                    case 'help':
+                        if (cmdObj.args.length === 0) {
+                            reply(`推特搬运机器人:
+/twitter - 查询当前聊天中的推文订阅
+/twitter_subscribe〈链接|用户名〉- 订阅 Twitter 推文搬运
+/twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
+/twitter_view〈链接〉- 查看推文
+/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
+${chat.chatType === "temp" ?
+                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
+                        }
+                        else if (cmdObj.args[0] === 'twitter_query') {
+                            reply(`查询时间线中的推文:
+/twitter_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 并用时,取二者中实际查询结果较少者
+例子:/twitter_query RiccaTachibana count=5 since="2019-12-30\
+ UTC+9" until="2020-01-06 UTC+8" norts=on
+    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条推文,\
+其中不包含原生转推(实际上用户只发了 1 条)`));
+                        }
+                }
+            }));
+        };
+        this.connect = () => {
+            this.initBot();
+            this.app.start().then(() => 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;

+ 2 - 2
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 = [
@@ -106,7 +106,7 @@ if (!config.resume_on_start) {
         lock.threads[key].offset = '-1';
     });
 }
-const qq = new mirai_1.default({
+const qq = new koishi_1.default({
     access_token: config.mirai_access_token,
     host: config.mirai_http_host,
     port: config.mirai_http_port,

+ 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 'twitter_view':
-                    case 'twitter_get':
-                        command_1.view(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitter_query':
-                    case 'twitter_gettimeline':
-                        command_1.query(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitter_sub':
-                    case 'twitter_subscribe':
-                        this.botInfo.sub(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'twitter_unsub':
-                    case 'twitter_unsubscribe':
-                        this.botInfo.unsub(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'ping':
-                    case 'twitter':
-                        this.botInfo.list(chat, cmdObj.args, msg.reply);
-                        break;
-                    case 'help':
-                        if (cmdObj.args.length === 0) {
-                            msg.reply(`推特搬运机器人:
-/twitter - 查询当前聊天中的推文订阅
-/twitter_subscribe〈链接|用户名〉- 订阅 Twitter 推文搬运
-/twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
-/twitter_view〈链接〉- 查看推文
-/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
-${chat.chatType === "temp" ?
-                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
-                        }
-                        else if (cmdObj.args[0] === 'twitter_query') {
-                            msg.reply(`查询时间线中的推文:
-/twitter_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 并用时,取二者中实际查询结果较少者
-例子:/twitter_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;

+ 1 - 17
dist/twitter.js

@@ -48,7 +48,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 +126,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 = {

+ 46 - 76
dist/webshot.js

@@ -1,13 +1,4 @@
 "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 util_1 = require("util");
 const axios_1 = require("axios");
@@ -16,9 +7,8 @@ const html_entities_1 = require("html-entities");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
 const sharp = require("sharp");
-const gifski_1 = require("./gifski");
 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 {
@@ -57,7 +47,7 @@ class Webshot extends CallableInstance {
         this.renderWebshot = (url, height, webshotDelay) => {
             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')}`));
+                pic.toBuffer().then(buffer => resolve(`base64://${buffer.toString('base64')}`));
             });
             const promise = new Promise((resolve, reject) => {
                 const width = 720;
@@ -216,60 +206,41 @@ class Webshot extends CallableInstance {
             }).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 base64 = `base64://${Buffer.from(data).toString('base64')}`;
+                switch (ext) {
+                    case 'jpg':
+                    case 'png':
+                        return koishi_1.message.image(base64);
+                    case 'mp4':
+                        return koishi_1.message.video(base64);
+                }
+                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();
         }
@@ -277,7 +248,7 @@ class Webshot extends CallableInstance {
             this.connect(onready);
         }
     }
-    webshot(tweets, uploader, callback, webshotDelay) {
+    webshot(tweets, callback, webshotDelay) {
         let promise = new Promise(resolve => {
             resolve();
         });
@@ -286,7 +257,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}: `;
@@ -303,7 +274,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}`;
@@ -317,12 +288,12 @@ 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);
+                        return koishi_1.message.image(base64url);
+                    return author + text;
                 })
                     .then(msg => {
                     if (msg)
-                        messageChain.push(msg);
+                        messageChain += msg;
                 });
             }
             if (1 - this.mode % 2)
@@ -339,14 +310,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; });
                         }));
                     }
                 });
@@ -357,19 +327,19 @@ 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}`));
+                    messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${originTwi.quoted_status.id_str}`;
                 });
             }
             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.ellipseBase64InMessage(messageChain)));
                 callback(messageChain, xmlEntities.decode(text), author);
             });
         });

File diff suppressed because it is too large
+ 2 - 11
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",
     "pngjs": "^5.0.0",
     "puppeteer": "^2.1.0",
     "read-all-stream": "^3.1.0",

+ 187 - 0
src/koishi.ts

@@ -0,0 +1,187 @@
+import { App, Bot, segment, Session } from 'koishi';
+import 'koishi-adapter-onebot';
+
+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 const message = segment;
+export const ellipseBase64InMessage = (msg: string) => msg.replace(/(?<=\[CQ:.*base64:\/\/).*?(,|\])/, '...$1');
+
+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, msg: string) => (() => {
+    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 => {
+      logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
+      throw Error(reason);
+    });
+
+  private initBot = () => {
+    this.app = new App({
+      type: 'onebot',
+      server: `http://${this.botInfo.host}:${this.botInfo.port}`,
+      selfId: this.botInfo.bot_id.toString(),
+      token: this.botInfo.access_token,
+      onebot: {
+        quickOperation: 100,
+      },
+      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 'twitter_view':
+        case 'twitter_get':
+          view(chat, cmdObj.args, reply);
+          break;
+        case 'twitter_query':
+        case 'twitter_gettimeline':
+          query(chat, cmdObj.args, reply);
+          break;
+        case 'twitter_sub':
+        case 'twitter_subscribe':
+          this.botInfo.sub(chat, cmdObj.args, reply);
+          break;
+        case 'twitter_unsub':
+        case 'twitter_unsubscribe':
+          this.botInfo.unsub(chat, cmdObj.args, reply);
+          break;
+        case 'ping':
+        case 'twitter':
+          this.botInfo.list(chat, cmdObj.args, reply);
+          break;
+        case 'help':
+          if (cmdObj.args.length === 0) {
+            reply(`推特搬运机器人:
+/twitter - 查询当前聊天中的推文订阅
+/twitter_subscribe〈链接|用户名〉- 订阅 Twitter 推文搬运
+/twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
+/twitter_view〈链接〉- 查看推文
+/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
+${chat.chatType === ChatType.Temp ?
+    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
+}`);
+          } else if (cmdObj.args[0] === 'twitter_query') {
+            reply(`查询时间线中的推文:
+/twitter_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 并用时,取二者中实际查询结果较少者
+例子:/twitter_query RiccaTachibana count=5 since="2019-12-30\
+ UTC+9" until="2020-01-06 UTC+8" norts=on
+    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条推文,\
+其中不包含原生转推(实际上用户只发了 1 条)`)
+              );
+          }
+      }
+    });
+  };
+
+  public connect = () => {
+    this.initBot();
+    this.app.start().then(() => 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;
+  }
+}

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

+ 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 'twitter_view':
-        case 'twitter_get':
-          view(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitter_query':
-        case 'twitter_gettimeline':
-          query(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitter_sub':
-        case 'twitter_subscribe':
-          this.botInfo.sub(chat, cmdObj.args, msg.reply);
-          break;
-        case 'twitter_unsub':
-        case 'twitter_unsubscribe':
-          this.botInfo.unsub(chat, cmdObj.args, msg.reply);
-          break;
-        case 'ping':
-        case 'twitter':
-          this.botInfo.list(chat, cmdObj.args, msg.reply);
-          break;
-        case 'help':
-          if (cmdObj.args.length === 0) {
-            msg.reply(`推特搬运机器人:
-/twitter - 查询当前聊天中的推文订阅
-/twitter_subscribe〈链接|用户名〉- 订阅 Twitter 推文搬运
-/twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
-/twitter_view〈链接〉- 查看推文
-/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
-${chat.chatType === ChatType.Temp ?
-    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
-}`);
-          } else if (cmdObj.args[0] === 'twitter_query') {
-            msg.reply(`查询时间线中的推文:
-/twitter_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 并用时,取二者中实际查询结果较少者
-例子:/twitter_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;
-  }
-}

+ 5 - 24
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 from './koishi';
 import { chainPromises, BigNumOps } from './utils';
 import Webshot from './webshot';
 
@@ -69,7 +69,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) {
@@ -255,28 +254,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,
@@ -289,7 +270,7 @@ export default class {
       });
   };
 
-  private sendTweets = (source?: string, ...to: IChat[]) => (msg: MessageChain, text: string, author: string) => {
+  private sendTweets = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
     to.forEach(subscriber => {
       logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
       retryOnError(

+ 1 - 1
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';

+ 40 - 71
src/webshot.ts

@@ -9,9 +9,8 @@ import * as puppeteer from 'puppeteer';
 import { Browser } from 'puppeteer';
 import * as sharp from 'sharp';
 
-import gifski from './gifski';
 import { getLogger } from './loggers';
-import { Message, MessageChain } from './mirai';
+import { ellipseBase64InMessage, message } from './koishi';
 import { MediaEntity, Tweets } from './twitter';
 import { chainPromises } from './utils';
 
@@ -30,9 +29,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: Browser;
   private mode: number;
@@ -72,7 +69,7 @@ class Webshot extends CallableInstance<[
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
     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')}`));
+      pic.toBuffer().then(buffer => resolve(`base64://${buffer.toString('base64')}`));
     });
     const promise = new Promise<{ base64: string, boundary: null | number }>((resolve, reject) => {
       const width = 720;
@@ -245,66 +242,43 @@ class Webshot extends CallableInstance<[
     );
   };
 
-  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 base64 = `base64://${Buffer.from(data).toString('base64')}`;
       switch (ext) {
         case 'jpg':
-          return {mimetype: 'image/jpeg', data};
         case 'png':
-          return {mimetype: 'image/png', data};
+          return message.image(base64);
         case 'mp4':
-          try {
-            return {mimetype: 'image/gif', data: await gif(data)};
-          } catch (err) {
-            logger.error(err);
-            throw Error(err);
-          }
+          return message.video(base64);
       }
+      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 => {
@@ -315,7 +289,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`;
@@ -334,7 +308,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
@@ -351,11 +325,11 @@ 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);
+            if (base64url) return message.image(base64url);
+            return author + text;
           })
           .then(msg => {
-            if (msg) messageChain.push(msg);
+            if (msg) messageChain += msg;
           });
       }
       // fetch extra entities
@@ -373,16 +347,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; });
           }));
         }
       });
@@ -394,7 +365,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('');
             }
           });
         }
@@ -402,14 +373,12 @@ 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}`)
-          );
+          messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${originTwi.quoted_status.id_str}`;
         });
       }
       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(ellipseBase64InMessage(messageChain)));
         callback(messageChain, xmlEntities.decode(text), author);
       });
     });

File diff suppressed because it is too large
+ 2 - 2
src/webshot_test.js


Some files were not shown because too many files changed in this diff