Ver Fonte

upload audio as voice for videos with audio

Mike L há 4 anos atrás
pai
commit
6df6573cfd
9 ficheiros alterados com 274 adições e 82 exclusões
  1. 26 2
      dist/gifski.js
  2. 56 36
      dist/mirai.js
  3. 1 1
      dist/twitter.js
  4. 55 5
      dist/webshot.js
  5. 1 1
      package.json
  6. 26 4
      src/gifski.ts
  7. 40 20
      src/mirai.ts
  8. 3 3
      src/twitter.ts
  9. 66 10
      src/webshot.ts

+ 26 - 2
dist/gifski.js

@@ -16,6 +16,7 @@ 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;
+const isEmpty = (path) => fs_1.statSync(path).size === 0;
 function default_1(data, targetWidth) {
     return __awaiter(this, void 0, void 0, function* () {
         const outputFilePath = temp.path({ suffix: '.gif' });
@@ -24,6 +25,19 @@ function default_1(data, targetWidth) {
             const inputFile = temp.openSync();
             fs_1.writeSync(inputFile.fd, Buffer.from(data));
             fs_1.closeSync(inputFile.fd);
+            child_process_1.spawnSync('ffmpeg', [
+                '-i',
+                inputFile.path,
+                '-c:a', 'copy',
+                '-vn',
+                inputFile.path + '.mka',
+            ]);
+            if (fs_1.statSync(inputFile.path + '.mka').size === 0) {
+                fs_1.unlinkSync(inputFile.path + '.mka');
+            }
+            else {
+                logger.info(`extracted audio to ${inputFile.path + '.mka'}`);
+            }
             logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
             const args = [
                 inputFile.path,
@@ -49,8 +63,18 @@ function default_1(data, targetWidth) {
                     clearInterval(sizeChecker);
                     if (!fs_1.existsSync(outputFilePath))
                         reject('no file was created on exit');
-                    logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
-                    resolve(fs_1.readFileSync(outputFilePath).buffer);
+                    logger.info('gif conversion succeeded, remuxing to mkv...');
+                    child_process_1.spawnSync('ffmpeg', [
+                        '-i',
+                        outputFilePath,
+                        ...fs_1.existsSync(inputFile.path + '.mka') ? ['-i', inputFile.path + '.mka'] : [],
+                        '-c', 'copy',
+                        outputFilePath + '.mkv',
+                    ]);
+                    if (isEmpty(outputFilePath + '.mkv'))
+                        reject('remux to mkv failed');
+                    logger.info(`mkv remuxing succeeded, file path: ${outputFilePath}.mkv`);
+                    resolve(fs_1.readFileSync(outputFilePath + '.mkv').buffer);
                 });
             });
             const stderr = [];

+ 56 - 36
dist/mirai.js

@@ -51,47 +51,65 @@ class default_1 {
                     };
             }
         });
-        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);
-                // 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);
-        });
-        this.uploadPic = (img, timeout = -1) => {
+        this.sendTo = (subscriber, msg) => {
+            const chain = [];
+            const voices = [];
+            return (() => {
+                if (typeof msg !== 'string') {
+                    msg.forEach(singleMsg => (singleMsg.type === 'Voice' ? voices : chain).push(singleMsg));
+                    msg = chain;
+                }
+                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);
+            })
+                .then(() => {
+                if (voices.length && subscriber.chatType === 'group') {
+                    voices.forEach((voice, index) => this.bot.api.sendGroupMessage([voice], subscriber.chatID)
+                        .then(voiceResponse => {
+                        logger.info(`pushing voice #${index} to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
+                        logger.info(voiceResponse);
+                    }));
+                }
+            })
+                .catch(reason => {
+                logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
+                throw Error(reason);
+            });
+        };
+        this.upload = (mediaMsg, timeout = -1) => {
+            const idKeyName = `${mediaMsg.type.toLowerCase()}Id`;
             if (timeout)
                 timeout = Math.floor(timeout);
             if (timeout === 0 || timeout < -1) {
                 return Promise.reject('Error: timeout must be greater than 0ms');
             }
-            let imgFile;
-            if (img.imageId !== '')
+            let file;
+            if (mediaMsg[idKeyName] !== '')
                 return Promise.resolve();
-            if (img.url !== '') {
-                if (img.url.split(':')[0] !== 'data') {
+            if (mediaMsg.url !== '') {
+                if (mediaMsg.url.split(':')[0] !== 'data') {
                     return Promise.reject('Error: URL must be of protocol "data"');
                 }
-                if (img.url.split(',')[0].split(';')[1] !== 'base64') {
+                if (mediaMsg.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.writeSync(tempFile.fd, Buffer.from(mediaMsg.url.split(',')[1], 'base64'));
                     fs_1.closeSync(tempFile.fd);
-                    imgFile = tempFile.path;
+                    file = tempFile.path;
                 }
                 catch (error) {
                     logger.error(error);
@@ -99,16 +117,18 @@ class default_1 {
             }
             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', imgFile || img.path)
+                logger.info(`uploading ${JSON.stringify(exports.Message[mediaMsg.type](mediaMsg[idKeyName], `${mediaMsg.url.split(',')[0]},[...]`, mediaMsg.path))}...`);
+                return this.bot.api[`upload${mediaMsg.type}`]('group', file || mediaMsg.path)
                     .then(response => {
-                    logger.info(`uploading ${img.path} as group image was successful, response:`);
+                    logger.info(`uploading ${mediaMsg.path} as group ${mediaMsg.type.toLowerCase()} was successful, response:`);
                     logger.info(JSON.stringify(response));
-                    img.url = '';
-                    img.path = response.path.split(/[/\\]/).slice(-1)[0];
+                    if (mediaMsg.type === 'Voice')
+                        mediaMsg[idKeyName] = response[idKeyName];
+                    mediaMsg.url = '';
+                    mediaMsg.path = response.path.split(/[/\\]/).slice(-1)[0];
                 })
                     .catch(reason => {
-                    logger.error(`error uploading ${img.path}, reason: ${reason}`);
+                    logger.error(`error uploading ${mediaMsg.path}, reason: ${reason}`);
                     throw Error(reason);
                 });
             }
@@ -124,13 +144,13 @@ class default_1 {
                 host: this.botInfo.host,
                 port: this.botInfo.port,
             });
-            this.bot.axios.defaults.maxContentLength = Infinity;
+            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('allow');
+                        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})`);
@@ -142,7 +162,7 @@ class default_1 {
                 this.bot.api.friendList()
                     .then((friendList) => {
                     if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
-                        evt.respond('allow');
+                        evt.respond(0);
                         return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
                     }
                     logger.warn(`received group invitation from ${evt.fromId} (unknown)`);

+ 1 - 1
dist/twitter.js

@@ -128,7 +128,7 @@ class default_1 {
         this.workOnTweets = (tweets, sendTweets) => {
             const uploader = (message, lastResort) => {
                 let timeout = uploadTimeout;
-                return retryOnError(() => this.bot.uploadPic(message, timeout).then(() => message), (_, count, terminate) => {
+                return retryOnError(() => this.bot.upload(message, timeout).then(() => message), (_, count, terminate) => {
                     if (count <= maxTrials) {
                         timeout *= (count + 2) / (count + 1);
                         logger.warn(`retry uploading for the ${ordinal(count)} time...`);

+ 55 - 5
dist/webshot.js

@@ -11,10 +11,13 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
 Object.defineProperty(exports, "__esModule", { value: true });
 const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
+const child_process_1 = require("child_process");
+const fs_1 = require("fs");
 const html_entities_1 = require("html-entities");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
 const sharp = require("sharp");
+const temp = require("temp");
 const util_1 = require("util");
 const gifski_1 = require("./gifski");
 const loggers_1 = require("./loggers");
@@ -270,7 +273,7 @@ class Webshot extends CallableInstance {
                             return { mimetype: 'image/png', data };
                         case 'mp4':
                             try {
-                                return { mimetype: 'image/gif', data: yield gif(data) };
+                                return { mimetype: 'video/x-matroska', data: yield gif(data) };
                             }
                             catch (err) {
                                 logger.error(err);
@@ -358,13 +361,60 @@ class Webshot extends CallableInstance {
                             }
                             const altMessage = mirai_1.Message.Plain(`\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))
+                                .then(base64url => {
+                                let mediaPromise = Promise.resolve([]);
+                                if (base64url.match(/^data:video.+;/)) {
+                                    // demux mkv into gif and pcm16le
+                                    const input = () => Buffer.from(base64url.split(',')[1], 'base64');
+                                    const imgReturns = child_process_1.spawnSync('ffmpeg', [
+                                        '-i', '-',
+                                        '-an',
+                                        '-f', 'gif',
+                                        '-c', 'copy',
+                                        '-',
+                                    ], { stdio: 'pipe', maxBuffer: 16 * 1024 * 1024, input: input() });
+                                    const voiceReturns = child_process_1.spawnSync('ffmpeg', [
+                                        '-i', '-',
+                                        '-vn',
+                                        '-f', 's16le',
+                                        '-ac', '1',
+                                        '-ar', '24000',
+                                        '-',
+                                    ], { stdio: 'pipe', maxBuffer: 16 * 1024 * 1024, input: input() });
+                                    if (!imgReturns.stdout)
+                                        throw Error(imgReturns.stderr.toString());
+                                    base64url = `data:image/gif;base64,${imgReturns.stdout.toString('base64')}`;
+                                    if (voiceReturns.stdout) {
+                                        logger.info('video has an audio track, trying to convert it to voice...');
+                                        temp.track();
+                                        const inputFile = temp.openSync();
+                                        fs_1.writeSync(inputFile.fd, voiceReturns.stdout);
+                                        child_process_1.spawnSync('silk-encoder', [
+                                            inputFile.path,
+                                            inputFile.path + '.silk',
+                                            '-tencent',
+                                        ]);
+                                        temp.cleanup();
+                                        if (fs_1.existsSync(inputFile.path + '.silk')) {
+                                            if (fs_1.statSync(inputFile.path + '.silk').size !== 0) {
+                                                const audioBase64Url = `data:audio/silk-v3;base64,${fs_1.readFileSync(inputFile.path + '.silk').toString('base64')}`;
+                                                mediaPromise = mediaPromise.then(chain => uploader(mirai_1.Message.Voice('', audioBase64Url, `${url} as amr`), () => mirai_1.Message.Plain('\n[失败的语音]'))
+                                                    .then(msg => [msg, ...chain]));
+                                            }
+                                            fs_1.unlinkSync(inputFile.path + '.silk');
+                                        }
+                                    }
+                                }
+                                return mediaPromise.then(chain => uploader(mirai_1.Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
+                                    .then(msg => [msg, ...chain]));
+                            })
                                 .catch(error => {
+                                logger.error(`unable to fetch media, error: ${error}`);
                                 logger.warn('unable to fetch media, sending plain text instead...');
-                                return altMessage;
+                                return [altMessage];
                             })
-                                .then(msg => {
-                                messageChain.push(msg);
+                                .then(msgs => {
+                                messageChain.push(...msgs);
                             });
                         }));
                     }

+ 1 - 1
package.json

@@ -34,7 +34,7 @@
     "command-line-usage": "^5.0.5",
     "html-entities": "^1.3.1",
     "log4js": "^6.3.0",
-    "mirai-ts": "github:CL-Jeremy/mirai-ts#built",
+    "mirai-ts": "^0.7.4",
     "pngjs": "^5.0.0",
     "puppeteer": "^2.1.0",
     "read-all-stream": "^3.1.0",

+ 26 - 4
src/gifski.ts

@@ -1,5 +1,5 @@
-import { spawn } from 'child_process';
-import { closeSync, existsSync, readFileSync, statSync, writeSync } from 'fs';
+import { spawn, spawnSync } from 'child_process';
+import { closeSync, existsSync, readFileSync, statSync, unlinkSync, writeSync, PathLike } from 'fs';
 import * as temp from 'temp';
 
 import { getLogger } from './loggers';
@@ -8,6 +8,7 @@ const logger = getLogger('gifski');
 
 const sizeLimit = 10 * 2 ** 20;
 const roundToEven = (n: number) => Math.ceil(n / 2) * 2;
+const isEmpty = (path: PathLike) => statSync(path).size === 0;
 
 export default async function (data: ArrayBuffer, targetWidth?: number) {
     const outputFilePath = temp.path({suffix: '.gif'});
@@ -16,6 +17,18 @@ export default async function (data: ArrayBuffer, targetWidth?: number) {
       const inputFile = temp.openSync();
       writeSync(inputFile.fd, Buffer.from(data));
       closeSync(inputFile.fd);
+      spawnSync('ffmpeg', [
+        '-i',
+        inputFile.path,
+        '-c:a', 'copy',
+        '-vn',
+        inputFile.path + '.mka',
+      ]);
+      if (statSync(inputFile.path + '.mka').size === 0) {
+        unlinkSync(inputFile.path + '.mka');
+      } else {
+        logger.info(`extracted audio to ${inputFile.path + '.mka'}`);
+      }
       logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
       const args = [
         inputFile.path,
@@ -39,8 +52,17 @@ export default async function (data: ArrayBuffer, targetWidth?: number) {
         gifskiSpawn.on('exit', () => {
           clearInterval(sizeChecker);
           if (!existsSync(outputFilePath)) reject('no file was created on exit');
-          logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
-          resolve(readFileSync(outputFilePath).buffer);
+          logger.info('gif conversion succeeded, remuxing to mkv...');
+          spawnSync('ffmpeg', [
+            '-i',
+            outputFilePath,
+            ...existsSync(inputFile.path + '.mka') ? ['-i', inputFile.path + '.mka'] : [],
+            '-c', 'copy',
+            outputFilePath + '.mkv',
+          ]);
+          if (isEmpty(outputFilePath + '.mkv')) reject('remux to mkv failed');
+          logger.info(`mkv remuxing succeeded, file path: ${outputFilePath}.mkv`);
+          resolve(readFileSync(outputFilePath + '.mkv').buffer);
         });
       });
       const stderr = [];

+ 40 - 20
src/mirai.ts

@@ -62,8 +62,14 @@ export default class {
     }
   }
 
-  public sendTo = (subscriber: IChat, msg: string | MessageChain) =>
-    (() => {
+  public sendTo = (subscriber: IChat, msg: string | MessageChain) => {
+    const chain = [];
+    const voices = [];
+    return (() => {
+      if (typeof msg !== 'string') {
+        msg.forEach(singleMsg => (singleMsg.type === 'Voice' ? voices : chain).push(singleMsg));
+        msg = chain;
+      }
       switch (subscriber.chatType) {
         case 'group':
           return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
@@ -78,31 +84,44 @@ export default class {
       logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
       logger.info(response);
     })
+    .then(() => {
+      if (voices.length && subscriber.chatType === 'group') {
+        voices.forEach((voice, index) =>
+          this.bot.api.sendGroupMessage([voice], subscriber.chatID)
+          .then(voiceResponse => {
+            logger.info(`pushing voice #${index} to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
+            logger.info(voiceResponse);
+          })
+        );
+      }
+    })
     .catch(reason => {
       logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
       throw Error(reason);
-    })
+    });
+  }
 
-  public uploadPic = (img: MessageType.Image, timeout = -1) => {
+  public upload = <T extends MessageType.Image | MessageType.Voice>(mediaMsg: T, timeout = -1) => {
+    const idKeyName = `${mediaMsg.type.toLowerCase()}Id`;
     if (timeout) timeout = Math.floor(timeout);
     if (timeout === 0 || timeout < -1) {
       return Promise.reject('Error: timeout must be greater than 0ms');
     }
-    let imgFile: string;
-    if (img.imageId !== '') return Promise.resolve();
-    if (img.url !== '') {
-      if (img.url.split(':')[0] !== 'data') {
+    let file: string;
+    if (mediaMsg[idKeyName] !== '') return Promise.resolve();
+    if (mediaMsg.url !== '') {
+      if (mediaMsg.url.split(':')[0] !== 'data') {
         return Promise.reject('Error: URL must be of protocol "data"');
       }
-      if (img.url.split(',')[0].split(';')[1] !== 'base64') {
+      if (mediaMsg.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'));
+        writeSync(tempFile.fd, Buffer.from(mediaMsg.url.split(',')[1], 'base64'));
         closeSync(tempFile.fd);
-        imgFile = tempFile.path;
+        file = tempFile.path;
       } catch (error) {
         logger.error(error);
       }
@@ -110,17 +129,18 @@ export default class {
     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)
+        Message[mediaMsg.type](mediaMsg[idKeyName], `${mediaMsg.url.split(',')[0]},[...]`, mediaMsg.path)
       )}...`);
-      return this.bot.api.uploadImage('group', imgFile || img.path)
+      return this.bot.api[`upload${mediaMsg.type}`]('group', file || mediaMsg.path)
       .then(response => { // workaround for https://github.com/mamoe/mirai/issues/194
-        logger.info(`uploading ${img.path} as group image was successful, response:`);
+        logger.info(`uploading ${mediaMsg.path} as group ${mediaMsg.type.toLowerCase()} was successful, response:`);
         logger.info(JSON.stringify(response));
-        img.url = '';
-        img.path = (response.path as string).split(/[/\\]/).slice(-1)[0];
+        if (mediaMsg.type === 'Voice') mediaMsg[idKeyName] = response[idKeyName];
+        mediaMsg.url = '';
+        mediaMsg.path = (response.path as string).split(/[/\\]/).slice(-1)[0];
       })
       .catch(reason => {
-        logger.error(`error uploading ${img.path}, reason: ${reason}`);
+        logger.error(`error uploading ${mediaMsg.path}, reason: ${reason}`);
         throw Error(reason);
       });
     } finally {
@@ -137,7 +157,7 @@ export default class {
       port: this.botInfo.port,
     });
 
-    this.bot.axios.defaults.maxContentLength = Infinity;
+    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)}`);
@@ -148,7 +168,7 @@ export default class {
         permission: 'OWNER' | 'ADMINISTRATOR' | 'MEMBER',
       }]) => {
         if (groupList.some(groupItem => groupItem.id === evt.groupId)) {
-          evt.respond('allow');
+          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})`);
@@ -165,7 +185,7 @@ export default class {
         remark: string,
       }]) => {
         if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
-          evt.respond('allow');
+          evt.respond(0);
           return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
         }
         logger.warn(`received group invitation from ${evt.fromId} (unknown)`);

+ 3 - 3
src/twitter.ts

@@ -262,13 +262,13 @@ export default class {
     tweets: Tweets,
     sendTweets: (msg: MessageChain, text: string, author: string) => void
   ) => {
-    const uploader = (
-      message: ReturnType<typeof Message.Image>,
+    const uploader = <T extends ReturnType<typeof Message.Image | typeof Message.Voice>>(
+      message: T,
       lastResort: (...args) => ReturnType<typeof Message.Plain>
     ) => {
       let timeout = uploadTimeout;
       return retryOnError(() =>
-        this.bot.uploadPic(message, timeout).then(() => message),
+        this.bot.upload(message, timeout).then(() => message),
       (_, count, terminate: (defaultValue: ReturnType<typeof Message.Plain>) => void) => {
         if (count <= maxTrials) {
           timeout *= (count + 2) / (count + 1);

+ 66 - 10
src/webshot.ts

@@ -1,11 +1,14 @@
 import axios from 'axios';
 import * as CallableInstance from 'callable-instance';
+import { spawnSync } from 'child_process';
+import { existsSync, readFileSync, statSync, unlinkSync, writeSync } from 'fs';
 import { XmlEntities } from 'html-entities';
 import { PNG } from 'pngjs';
 import * as puppeteer from 'puppeteer';
 import { Browser } from 'puppeteer';
 import * as sharp from 'sharp';
 import { Readable } from 'stream';
+import * as temp from 'temp';
 import { promisify } from 'util';
 
 import gifski from './gifski';
@@ -282,7 +285,7 @@ extends CallableInstance<
             return {mimetype: 'image/png', data};
           case 'mp4':
             try {
-              return {mimetype: 'image/gif', data: await gif(data)};
+              return {mimetype: 'video/x-matroska', data: await gif(data)};
             } catch (err) {
               logger.error(err);
               throw Error(err);
@@ -300,10 +303,10 @@ extends CallableInstance<
 
   public webshot(
     tweets: Tweets,
-    uploader: (
-      img: ReturnType<typeof Message.Image>,
+    uploader: <T extends ReturnType<typeof Message.Image | typeof Message.Voice>>(
+      msg: T,
       lastResort: (...args) => ReturnType<typeof Message.Plain>)
-      => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
+      => Promise<T | ReturnType<typeof Message.Plain>>,
     callback: (msgs: MessageChain, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
@@ -373,15 +376,68 @@ extends CallableInstance<
             }
             const altMessage = Message.Plain(`\n[失败的${typeInZH[media.type].type}:${url}]`);
             return this.fetchMedia(url)
-              .then(base64url =>
-                uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
-              )
+              .then(base64url => {
+                let mediaPromise = Promise.resolve([] as (
+                  Parameters<typeof uploader>[0] |
+                  ReturnType<Parameters<typeof uploader>[1]>
+                )[]);
+                if (base64url.match(/^data:video.+;/)) {
+                  // demux mkv into gif and pcm16le
+                  const input = () => Buffer.from(base64url.split(',')[1], 'base64');
+                  const imgReturns = spawnSync('ffmpeg', [
+                    '-i', '-',
+                    '-an',
+                    '-f', 'gif',
+                    '-c', 'copy',
+                    '-',
+                  ], {stdio: 'pipe', maxBuffer: 16 * 1024 * 1024, input: input()});
+                  const voiceReturns = spawnSync('ffmpeg', [
+                    '-i', '-',
+                    '-vn',
+                    '-f', 's16le',
+                    '-ac', '1',
+                    '-ar', '24000',
+                    '-',
+                  ], {stdio: 'pipe', maxBuffer: 16 * 1024 * 1024, input: input()});
+                  if (!imgReturns.stdout) throw Error(imgReturns.stderr.toString());
+                  base64url = `data:image/gif;base64,${imgReturns.stdout.toString('base64')}`;
+                  if (voiceReturns.stdout) {
+                    logger.info('video has an audio track, trying to convert it to voice...');
+                    temp.track();
+                    const inputFile = temp.openSync();
+                    writeSync(inputFile.fd, voiceReturns.stdout);
+                    spawnSync('silk-encoder', [
+                      inputFile.path,
+                      inputFile.path + '.silk',
+                      '-tencent',
+                    ]);
+                    temp.cleanup();
+                    if (existsSync(inputFile.path + '.silk')) {
+                      if (statSync(inputFile.path + '.silk').size !== 0) {
+                        const audioBase64Url = `data:audio/silk-v3;base64,${
+                          readFileSync(inputFile.path + '.silk').toString('base64')
+                        }`;
+                        mediaPromise = mediaPromise.then(chain =>
+                          uploader(Message.Voice('', audioBase64Url, `${url} as amr`), () => Message.Plain('\n[失败的语音]'))
+                          .then(msg => [msg, ...chain])
+                        );
+                      }
+                      unlinkSync(inputFile.path + '.silk');
+                    }
+                  }
+                }
+                return mediaPromise.then(chain =>
+                  uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
+                  .then(msg => [msg, ...chain])
+                );
+              })
               .catch(error => {
+                logger.error(`unable to fetch media, error: ${error}`);
                 logger.warn('unable to fetch media, sending plain text instead...');
-                return altMessage;
+                return [altMessage];
               })
-              .then(msg => {
-                messageChain.push(msg);
+              .then(msgs => {
+                messageChain.push(...msgs);
               });
           }));
         }