Bladeren bron

Merge branch 'instagram' into stories

Mike L 3 jaren geleden
bovenliggende
commit
fa83eb56b9
7 gewijzigde bestanden met toevoegingen van 192 en 37 verwijderingen
  1. 1 0
      config.example.json
  2. 31 10
      dist/koishi.js
  3. 7 0
      dist/main.js
  4. 52 6
      dist/twitter.js
  5. 36 12
      src/koishi.ts
  6. 8 0
      src/main.ts
  7. 57 9
      src/twitter.ts

+ 1 - 0
config.example.json

@@ -7,6 +7,7 @@
   "ig_username": "",
   "ig_password": "",
   "ig_session_lockfile": "",
+  "ig_2fa_code_receiver_port": 8081,
   "mode": 0,
   "playwright_ws_spec_endpoint": "http://127.0.0.1:8080/playwright-ws.json",
   "resume_on_start": false,

+ 31 - 10
dist/koishi.js

@@ -14,6 +14,7 @@ const koishi_1 = require("koishi");
 require("koishi-adapter-onebot");
 const command_1 = require("./command");
 const loggers_1 = require("./loggers");
+const utils_1 = require("./utils");
 const logger = loggers_1.getLogger('qqbot');
 const cqUrlFix = (factory) => (...args) => factory(...args).replace(/(?<=\[CQ:.*)url=(?=(base64|file|https?):\/\/)/, 'file=');
 exports.Message = {
@@ -33,6 +34,7 @@ exports.Message = {
 class default_1 {
     constructor(opt) {
         this.messageQueues = {};
+        this.tempSenders = {};
         this.next = (type, id) => {
             const queue = this.messageQueues[`${type}:${id}`];
             if (queue && queue.length) {
@@ -57,13 +59,14 @@ class default_1 {
         this.getChat = (session) => __awaiter(this, void 0, void 0, function* () {
             switch (session.subtype) {
                 case 'private':
-                    if (session.groupId) {
+                    if (session.sender.groupId) {
                         const friendList = yield session.bot.getFriendList();
                         if (!friendList.some(friendItem => friendItem.userId === session.userId)) {
+                            this.tempSenders[session.userId] = session.sender.groupId;
                             return {
                                 chatID: {
                                     qq: Number(session.userId),
-                                    group: Number(session.groupId),
+                                    group: Number(session.sender.groupId),
                                 },
                                 chatType: "temp",
                             };
@@ -80,11 +83,11 @@ class default_1 {
                     };
             }
         });
-        this.sendToGroup = (groupID, message) => new Promise(resolve => {
-            this.enqueue('group', groupID, () => this.bot.sendMessage(groupID, message).then(resolve));
+        this.sendToGroup = (groupID, message) => new Promise((resolve, reject) => {
+            this.enqueue('group', groupID, () => this.bot.sendMessage(groupID, message).then(resolve).catch(reject));
         });
-        this.sendToUser = (userID, message) => new Promise(resolve => {
-            this.enqueue('private', userID, () => this.bot.sendPrivateMessage(userID, message).then(resolve));
+        this.sendToUser = (userID, message) => new Promise((resolve, reject) => {
+            this.enqueue('private', userID, () => this.bot.sendPrivateMessage(userID, message).then(resolve).catch(reject));
         });
         this.sendTo = (subscriber, messageChain) => Promise.all((splitted => [splitted.message, ...splitted.attachments])(exports.Message.separateAttachment(messageChain)).map(msg => {
             switch (subscriber.chatType) {
@@ -119,15 +122,33 @@ class default_1 {
             });
             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})`;
+                let groupId;
+                let groupString;
+                if (session.username in this.tempSenders)
+                    groupId = this.tempSenders[session.userId].toString();
                 logger.debug(`detected new friend request event: ${userString}`);
                 return session.bot.getGroupList().then(groupList => {
-                    if (groupList.some(groupItem => groupItem.groupId === session.groupId)) {
+                    if (groupList.some(groupItem => {
+                        const test = groupItem.groupId === groupId;
+                        if (test)
+                            groupString = `${groupItem.groupName}(${groupId})`;
+                        return test;
+                    })) {
                         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');
+                    utils_1.chainPromises(groupList.map(groupItem => (done) => Promise.resolve(done ||
+                        this.bot.getGroupMember(groupItem.groupId, session.userId).then(() => {
+                            groupString = `${groupItem.groupName}(${groupItem.groupId})`;
+                            session.bot.handleFriendRequest(session.messageId, true);
+                            logger.info(`accepted friend request from ${userString} (found in group ${groupString})`);
+                            return true;
+                        }).catch(() => false)))).then(done => {
+                        if (done)
+                            return;
+                        logger.warn(`received friend request from ${userString} (stranger)`);
+                        logger.warn('please manually accept this friend request');
+                    });
                 });
             }));
             this.app.on('group-request', (session) => __awaiter(this, void 0, void 0, function* () {

+ 7 - 0
dist/main.js

@@ -73,6 +73,12 @@ optionalFields.forEach(key => {
         config[key] = `${config.ig_username}.${key.replace('_lockfile', '.lock')}`;
     }
 });
+(k => {
+    if (!config[k] || config[k] < 2048 || config[k] > 65536) {
+        logger.warn(`invalid value of config.${k}, use ${exampleConfig[k]} as default`);
+        config[k] = exampleConfig[k];
+    }
+})('ig_2fa_code_receiver_port');
 loggers_1.setLogLevels(config.loglevel);
 let lock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
@@ -125,6 +131,7 @@ const qq = new koishi_1.default({
 const worker = new twitter_1.default({
     sessionLockfile: config.ig_session_lockfile,
     credentials: [config.ig_username, config.ig_password],
+    codeServicePort: config.ig_2fa_code_receiver_port,
     proxyUrl: config.ig_socks_proxy,
     lock,
     lockfile: config.lockfile,

+ 52 - 6
dist/twitter.js

@@ -10,8 +10,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
 };
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.sendAllStories = exports.ScreenNameNormalizer = exports.SessionManager = exports.parseLink = exports.linkBuilder = void 0;
+const crypto = require("crypto");
 const fs = require("fs");
+const http = require("http");
 const path = require("path");
+const url_1 = require("url");
+const util_1 = require("util");
 const instagram_private_api_1 = require("instagram-private-api");
 const socks_proxy_agent_1 = require("socks-proxy-agent");
 const loggers_1 = require("./loggers");
@@ -39,7 +43,7 @@ const linkBuilder = (config) => {
 };
 exports.linkBuilder = linkBuilder;
 class SessionManager {
-    constructor(client, file, credentials) {
+    constructor(client, file, credentials, codeServicePort) {
         this.init = () => {
             this.ig.state.generateDevice(this.username);
             this.ig.request.end$.subscribe(() => { this.save(); });
@@ -56,14 +60,55 @@ class SessionManager {
                     return Promise.resolve();
                 }
             }
-            else
-                return this.login();
+            else {
+                return this.login().catch((err) => {
+                    logger.error(`error while trying to log in as user ${this.username}, error: ${err}`);
+                    logger.warn('attempting to retry after 1 minute...');
+                    if (fs.existsSync(filePath))
+                        fs.unlinkSync(filePath);
+                    util_1.promisify(setTimeout)(60000).then(this.init);
+                });
+            }
         };
+        this.handle2FA = (submitter) => new Promise((resolve, reject) => {
+            const token = crypto.randomBytes(20).toString('hex');
+            logger.info('please submit the code with a one-time token from your browser with this path:');
+            logger.info(`/confirm-2fa?code=<the code you received>&token=${token}`);
+            let working;
+            const server = http.createServer((req, res) => {
+                const { pathname, query } = url_1.parse(req.url, true);
+                if (!working && pathname === '/confirm-2fa' && query.token === token &&
+                    typeof (query.code) === 'string' && /^\d{6}$/.test(query.code)) {
+                    const code = query.code;
+                    logger.debug(`received code: ${code}`);
+                    working = true;
+                    submitter(code)
+                        .then(response => { res.write('OK'); res.end(); server.close(() => resolve(response)); })
+                        .catch(err => { res.write('Error'); res.end(); reject(err); })
+                        .finally(() => { working = false; });
+                }
+            });
+            server.listen(this.codeServicePort);
+        });
         this.login = () => this.ig.simulate.preLoginFlow()
             .then(() => this.ig.account.login(this.username, this.password))
-            .then(() => new Promise(resolve => {
+            .catch((err) => {
+            if (err instanceof instagram_private_api_1.IgLoginTwoFactorRequiredError) {
+                const { two_factor_identifier, totp_two_factor_on } = err.response.body.two_factor_info;
+                logger.debug(`2FA info: ${JSON.stringify(err.response.body.two_factor_info)}`);
+                logger.info(`login is requesting two-factor authentication via ${totp_two_factor_on ? 'TOTP' : 'SMS'}`);
+                return this.handle2FA(code => this.ig.account.twoFactorLogin({
+                    username: this.username,
+                    verificationCode: code,
+                    twoFactorIdentifier: two_factor_identifier,
+                    verificationMethod: totp_two_factor_on ? '0' : '1',
+                }));
+            }
+            throw err;
+        })
+            .then(user => new Promise(resolve => {
             logger.info(`successfully logged in as ${this.username}`);
-            process.nextTick(() => resolve(this.ig.simulate.postLoginFlow()));
+            process.nextTick(() => resolve(this.ig.simulate.postLoginFlow().then(() => user)));
         }));
         this.save = () => this.ig.state.serialize()
             .then((serialized) => {
@@ -73,6 +118,7 @@ class SessionManager {
         this.ig = client;
         this.lockfile = file;
         [this.username, this.password] = credentials;
+        this.codeServicePort = codeServicePort;
     }
 }
 exports.SessionManager = SessionManager;
@@ -288,7 +334,7 @@ class default_1 {
                 logger.warn(`invalid socks proxy url: ${opt.proxyUrl}, ignoring`);
             }
         }
-        this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
+        this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials, opt.codeServicePort);
         this.lockfile = opt.lockfile;
         this.lock = opt.lock;
         this.workInterval = opt.workInterval;

+ 36 - 12
src/koishi.ts

@@ -1,11 +1,15 @@
 import { App, Bot, segment, Session, sleep } from 'koishi';
 import 'koishi-adapter-onebot';
+import { Message as CQMessage, SenderInfo } from 'koishi-adapter-onebot';
 
 import { parseCmd, view } from './command';
 import { getLogger } from './loggers';
+import { chainPromises } from './utils';
 
 const logger = getLogger('qqbot');
 
+type CQSession = Session & CQMessage & {sender: SenderInfo & {groupId?: number}};
+
 interface IQQProps {
   access_token: string;
   host: string;
@@ -42,6 +46,7 @@ export default class {
   public bot: Bot;
 
   private messageQueues: {[key: string]: (() => Promise<void>)[]} = {};
+  private tempSenders: {[key: number]: number} = {};
 
   private next = (type: 'private' | 'group', id: string) => {
     const queue = this.messageQueues[`${type}:${id}`];
@@ -62,16 +67,17 @@ export default class {
     if (wasEmpty) this.next(type, id);
   };
 
-  private getChat = async (session: Session): Promise<IChat> => {
+  private getChat = async (session: CQSession): Promise<IChat> => {
     switch (session.subtype) {
       case 'private':
-        if (session.groupId) { // temp message
+        if (session.sender.groupId) { // temp message
           const friendList = await session.bot.getFriendList();
           if (!friendList.some(friendItem => friendItem.userId === session.userId)) {
+            this.tempSenders[session.userId] = session.sender.groupId;
             return {
               chatID: {
                 qq: Number(session.userId),
-                group: Number(session.groupId),
+                group: Number(session.sender.groupId),
               },
               chatType: ChatType.Temp,
             };
@@ -89,12 +95,12 @@ export default class {
     }
   };
 
-  private sendToGroup = (groupID: string, message: string) => new Promise<string>(resolve => {
-    this.enqueue('group', groupID, () => this.bot.sendMessage(groupID, message).then(resolve));
+  private sendToGroup = (groupID: string, message: string) => new Promise<string>((resolve, reject) => {
+    this.enqueue('group', groupID, () => this.bot.sendMessage(groupID, message).then(resolve).catch(reject));
   });
 
-  private sendToUser = (userID: string, message: string) => new Promise<string>(resolve => {
-    this.enqueue('private', userID, () => this.bot.sendPrivateMessage(userID, message).then(resolve));
+  private sendToUser = (userID: string, message: string) => new Promise<string>((resolve, reject) => {
+    this.enqueue('private', userID, () => this.bot.sendPrivateMessage(userID, message).then(resolve).catch(reject));
   });
 
   public sendTo = (subscriber: IChat, messageChain: string) => Promise.all(
@@ -134,15 +140,33 @@ export default class {
 
     this.app.on('friend-request', async session => {
       const userString = `${session.username}(${session.userId})`;
-      const groupString = `${session.groupName}(${session.groupId})`;
+      let groupId: string;
+      let groupString: string;
+      if (session.username in this.tempSenders) groupId = this.tempSenders[session.userId as unknown as number].toString();
       logger.debug(`detected new friend request event: ${userString}`);
       return session.bot.getGroupList().then(groupList => {
-        if (groupList.some(groupItem => groupItem.groupId === session.groupId)) {
+        if (groupList.some(groupItem => {
+          const test = groupItem.groupId === groupId;
+          if (test) groupString = `${groupItem.groupName}(${groupId})`;
+          return test;
+        })) {
           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');
+        chainPromises(groupList.map(groupItem =>
+          (done: boolean) => Promise.resolve(done ||
+            this.bot.getGroupMember(groupItem.groupId, session.userId).then(() => {
+              groupString = `${groupItem.groupName}(${groupItem.groupId})`;
+              session.bot.handleFriendRequest(session.messageId, true);
+              logger.info(`accepted friend request from ${userString} (found in group ${groupString})`);
+              return true;
+            }).catch(() => false)
+          )
+        )).then(done => {
+          if (done) return;
+          logger.warn(`received friend request from ${userString} (stranger)`);
+          logger.warn('please manually accept this friend request');
+        });
       });
     });
 
@@ -160,7 +184,7 @@ export default class {
       });
     });
 
-    this.app.middleware(async session => {
+    this.app.middleware(async (session: CQSession) => {
       const chat = await this.getChat(session);
       const cmdObj = parseCmd(session.content);
       const reply = async msg => session.sendQueued(msg);

+ 8 - 0
src/main.ts

@@ -87,6 +87,13 @@ optionalFields.forEach(key => {
   }
 });
 
+(k => {
+  if (!config[k] || config[k] < 2048 || config[k] > 65536) {
+    logger.warn(`invalid value of config.${k}, use ${exampleConfig[k]} as default`);
+    config[k] = exampleConfig[k];
+  }
+})('ig_2fa_code_receiver_port');
+
 setLogLevels(config.loglevel);
 
 let lock: ILock;
@@ -140,6 +147,7 @@ const qq = new QQBot({
 const worker = new Worker({
   sessionLockfile: config.ig_session_lockfile,
   credentials: [config.ig_username, config.ig_password],
+  codeServicePort: config.ig_2fa_code_receiver_port,
   proxyUrl: config.ig_socks_proxy,
   lock,
   lockfile: config.lockfile,

+ 57 - 9
src/twitter.ts

@@ -1,10 +1,13 @@
+import * as crypto from 'crypto';
 import * as fs from 'fs';
+import * as http from 'http';
 import * as path from 'path';
+import { parse as parseUrl } from 'url';
+import { promisify } from 'util';
+
 import {
   IgApiClient,
-  IgClientError, IgExactUserNotFoundError,
-  IgLoginRequiredError,
-  IgNetworkError,
+  IgClientError, IgExactUserNotFoundError, IgLoginTwoFactorRequiredError, IgLoginRequiredError, IgNetworkError,
   ReelsMediaFeedResponseItem, UserFeedResponseUser
 } from 'instagram-private-api';
 import { RequestError } from 'request-promise/errors';
@@ -37,6 +40,7 @@ export {linkBuilder, parseLink};
 interface IWorkerOption {
   sessionLockfile: string;
   credentials: [string, string];
+  codeServicePort: number;
   proxyUrl: string;
   lock: ILock;
   lockfile: string;
@@ -53,11 +57,13 @@ export class SessionManager {
   private username: string;
   private password: string;
   private lockfile: string;
-  
-  constructor(client: IgApiClient, file: string, credentials: [string, string]) {
+  private codeServicePort: number;
+
+  constructor(client: IgApiClient, file: string, credentials: [string, string], codeServicePort: number) {
     this.ig = client;
     this.lockfile = file;
     [this.username, this.password] = credentials;
+    this.codeServicePort = codeServicePort;
   }
 
   public init = () => {
@@ -74,15 +80,57 @@ export class SessionManager {
         logger.error(`failed to load client session cookies from file ${this.lockfile}: `, err);
         return Promise.resolve();
       }
-    } else return this.login();
+    } else {
+      return this.login().catch((err: IgClientError) => {
+        logger.error(`error while trying to log in as user ${this.username}, error: ${err}`);
+        logger.warn('attempting to retry after 1 minute...');
+        if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+        promisify(setTimeout)(60000).then(this.init);
+      });
+    }
   };
 
+  public handle2FA = <T>(submitter: (code: string) => Promise<T>) => new Promise<T>((resolve, reject) => {
+    const token = crypto.randomBytes(20).toString('hex');
+    logger.info('please submit the code with a one-time token from your browser with this path:');
+    logger.info(`/confirm-2fa?code=<the code you received>&token=${token}`);
+    let working;
+    const server = http.createServer((req, res) => {
+      const {pathname, query} = parseUrl(req.url, true);
+      if (!working && pathname === '/confirm-2fa' && query.token === token &&
+        typeof(query.code) === 'string' && /^\d{6}$/.test(query.code)) {
+        const code = query.code;
+        logger.debug(`received code: ${code}`);
+        working = true;
+        submitter(code)
+          .then(response => { res.write('OK'); res.end(); server.close(() => resolve(response)); })
+          .catch(err => { res.write('Error'); res.end(); reject(err); })
+          .finally(() => { working = false; });
+      }
+    });
+    server.listen(this.codeServicePort);
+  });
+
   public login = () =>
     this.ig.simulate.preLoginFlow()
       .then(() => this.ig.account.login(this.username, this.password))
-      .then(() => new Promise(resolve => {
+      .catch((err: IgClientError) => {
+        if (err instanceof IgLoginTwoFactorRequiredError) {
+          const {two_factor_identifier, totp_two_factor_on} = err.response.body.two_factor_info;
+          logger.debug(`2FA info: ${JSON.stringify(err.response.body.two_factor_info)}`);
+          logger.info(`login is requesting two-factor authentication via ${totp_two_factor_on ? 'TOTP' : 'SMS'}`);
+          return this.handle2FA(code => this.ig.account.twoFactorLogin({
+            username: this.username,
+            verificationCode: code,
+            twoFactorIdentifier: two_factor_identifier,
+            verificationMethod: totp_two_factor_on ? '0' : '1',
+          }));
+        }
+        throw err;
+      })
+      .then(user => new Promise<typeof user>(resolve => {
         logger.info(`successfully logged in as ${this.username}`);
-        process.nextTick(() => resolve(this.ig.simulate.postLoginFlow()));
+        process.nextTick(() => resolve(this.ig.simulate.postLoginFlow().then(() => user)));
       }));
 
   public save = () =>
@@ -181,7 +229,7 @@ export default class {
         logger.warn(`invalid socks proxy url: ${opt.proxyUrl}, ignoring`);
       }
     }
-    this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
+    this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials, opt.codeServicePort);
     this.lockfile = opt.lockfile;
     this.lock = opt.lock;
     this.workInterval = opt.workInterval;