mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
fix: 初步解耦 session到远程
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
"ajv": "^8.13.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^13.0.0",
|
||||
"compressing": "^1.10.1",
|
||||
"cors": "^2.8.5",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint": "^9.14.0",
|
||||
@@ -52,18 +53,18 @@
|
||||
"globals": "^16.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"multer": "^2.0.1",
|
||||
"napcat.protobuf": "^1.1.4",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-cp": "^6.0.0",
|
||||
"vite-tsconfig-paths": "^5.1.0",
|
||||
"napcat.protobuf": "^1.1.4",
|
||||
"winston": "^3.17.0",
|
||||
"compressing": "^1.10.1"
|
||||
"superjson": "^2.2.2",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.0.0",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
}
|
@@ -142,7 +142,6 @@ export class NTQQMsgApi {
|
||||
}
|
||||
|
||||
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
|
||||
console.log(peer, SendersUid);
|
||||
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
|
||||
chatInfo: peer,
|
||||
filterMsgType: [],
|
||||
|
@@ -30,7 +30,7 @@ import os from 'node:os';
|
||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
|
||||
import { proxiedListenerOf } from '@/common/proxy-handler';
|
||||
import { NTQQPacketApi } from './apis/packet';
|
||||
import { createVirtualServiceClient, handleServiceServerOnce } from '@/framework/proxy/service';
|
||||
import { createVirtualSession } from './virtualsession';
|
||||
export * from './wrapper';
|
||||
export * from './types';
|
||||
export * from './services';
|
||||
@@ -98,8 +98,10 @@ export class NapCatCore {
|
||||
constructor(context: InstanceContext, selfInfo: SelfInfo) {
|
||||
this.selfInfo = selfInfo;
|
||||
this.context = context;
|
||||
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
this.eventWrapper = new NTEventWrapper(context.session);
|
||||
this.context.session = createVirtualSession(this.eventWrapper);
|
||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
|
||||
this.apis = {
|
||||
FileApi: new NTQQFileApi(this.context, this),
|
||||
@@ -169,13 +171,6 @@ export class NapCatCore {
|
||||
proxiedListenerOf(msgListener, this.context.logger),
|
||||
);
|
||||
|
||||
let msgServiceClient = createVirtualServiceClient('NodeIKernelMsgService', async (ServiceCommand, ...args) => {
|
||||
this.context.logger.log(`Client Outing->[${ServiceCommand}]`, ...args);
|
||||
return handleServiceServerOnce(ServiceCommand, async (listenerCommand: string, ...args: any[]) => {
|
||||
msgServiceClient.receiverListener(listenerCommand, ...args);
|
||||
}, this.eventWrapper, ...args);
|
||||
});
|
||||
console.log('msgServiceClient', await msgServiceClient.object.fetchFavEmojiList('', 50, true, true));
|
||||
const profileListener = new NodeIKernelProfileListener();
|
||||
profileListener.onProfileDetailInfoChanged = (profile) => {
|
||||
if (profile.uid === this.selfInfo.uid) {
|
||||
@@ -259,13 +254,13 @@ export async function genSessionConfig(
|
||||
}
|
||||
|
||||
export interface InstanceContext {
|
||||
readonly workingEnv: NapCatCoreWorkingEnv;
|
||||
readonly wrapper: WrapperNodeApi;
|
||||
readonly session: NodeIQQNTWrapperSession;
|
||||
readonly logger: LogWrapper;
|
||||
readonly loginService: NodeIKernelLoginService;
|
||||
readonly basicInfoWrapper: QQBasicInfoWrapper;
|
||||
readonly pathWrapper: NapCatPathWrapper;
|
||||
session: NodeIQQNTWrapperSession;
|
||||
workingEnv: NapCatCoreWorkingEnv;
|
||||
wrapper: WrapperNodeApi;
|
||||
logger: LogWrapper;
|
||||
loginService: NodeIKernelLoginService;
|
||||
basicInfoWrapper: QQBasicInfoWrapper;
|
||||
pathWrapper: NapCatPathWrapper;
|
||||
}
|
||||
|
||||
export interface StableNTApiWrapper {
|
||||
|
@@ -40,7 +40,6 @@ export class NodeIKernelBuddyListener {
|
||||
}
|
||||
|
||||
onDelBatchBuddyInfos(_arg: unknown): any {
|
||||
console.log('onDelBatchBuddyInfos not implemented', ...arguments);
|
||||
}
|
||||
|
||||
onDoubtBuddyReqChange(_arg:
|
||||
|
@@ -16,6 +16,16 @@ export * from './NodeIKernelDbToolsService';
|
||||
export * from './NodeIKernelTipOffService';
|
||||
export * from './NodeIKernelSearchService';
|
||||
export * from './NodeIKernelCollectionService';
|
||||
export * from './NodeIKernelAlbumService';
|
||||
export * from './NodeIKernelECDHService';
|
||||
export * from './NodeIKernelNodeMiscService';
|
||||
export * from './NodeIKernelMsgBackupService';
|
||||
export * from './NodeIKernelTianShuService';
|
||||
export * from './NodeIKernelUnitedConfigService';
|
||||
export * from './NodeIkernelTestPerformanceService';
|
||||
export * from './NodeIKernelUixConvertService';
|
||||
export * from './NodeIKernelMSFService';
|
||||
export * from './NodeIKernelRecentContactService';
|
||||
|
||||
import type {
|
||||
NodeIKernelAvatarService,
|
||||
@@ -37,6 +47,15 @@ import type {
|
||||
NodeIKernelTipOffService,
|
||||
} from '.';
|
||||
import { NodeIKernelAlbumService } from './NodeIKernelAlbumService';
|
||||
import { NodeIKernelECDHService } from './NodeIKernelECDHService';
|
||||
import { NodeIKernelNodeMiscService } from './NodeIKernelNodeMiscService';
|
||||
import { NodeIKernelMsgBackupService } from './NodeIKernelMsgBackupService';
|
||||
import { NodeIKernelTianShuService } from './NodeIKernelTianShuService';
|
||||
import { NodeIKernelUnitedConfigService } from './NodeIKernelUnitedConfigService';
|
||||
import { NodeIkernelTestPerformanceService } from './NodeIkernelTestPerformanceService';
|
||||
import { NodeIKernelUixConvertService } from './NodeIKernelUixConvertService';
|
||||
import { NodeIKernelMSFService } from './NodeIKernelMSFService';
|
||||
import { NodeIKernelRecentContactService } from './NodeIKernelRecentContactService';
|
||||
|
||||
export type ServiceNamingMapping = {
|
||||
NodeIKernelAlbumService: NodeIKernelAlbumService;
|
||||
@@ -55,6 +74,15 @@ export type ServiceNamingMapping = {
|
||||
NodeIKernelRichMediaService: NodeIKernelRichMediaService;
|
||||
NodeIKernelDbToolsService: NodeIKernelDbToolsService;
|
||||
NodeIKernelTipOffService: NodeIKernelTipOffService;
|
||||
NodeIKernelSearchService: NodeIKernelSearchService,
|
||||
NodeIKernelSearchService: NodeIKernelSearchService;
|
||||
NodeIKernelCollectionService: NodeIKernelCollectionService;
|
||||
NodeIKernelECDHService: NodeIKernelECDHService;
|
||||
NodeIKernelNodeMiscService: NodeIKernelNodeMiscService;
|
||||
NodeIKernelMsgBackupService: NodeIKernelMsgBackupService;
|
||||
NodeIKernelTianShuService: NodeIKernelTianShuService;
|
||||
NodeIKernelUnitedConfigService: NodeIKernelUnitedConfigService;
|
||||
NodeIkernelTestPerformanceService: NodeIkernelTestPerformanceService;
|
||||
NodeIKernelUixConvertService: NodeIKernelUixConvertService;
|
||||
NodeIKernelMSFService: NodeIKernelMSFService;
|
||||
NodeIKernelRecentContactService: NodeIKernelRecentContactService;
|
||||
};
|
||||
|
143
src/core/virtualSession.ts
Normal file
143
src/core/virtualSession.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NTEventWrapper } from "@/common/event";
|
||||
import { createVirtualServiceClient } from "@/framework/proxy/service";
|
||||
import { handleServiceServerOnce } from "@/framework/proxy/service";
|
||||
import { ServiceMethodCommand } from "@/framework/proxy/service";
|
||||
import {
|
||||
NodeIQQNTWrapperSession,
|
||||
WrapperSessionInitConfig
|
||||
} from "./wrapper";
|
||||
import { NodeIKernelSessionListener } from "./listeners/NodeIKernelSessionListener";
|
||||
import {
|
||||
NodeIDependsAdapter,
|
||||
NodeIDispatcherAdapter
|
||||
} from "./adapters";
|
||||
import superjson from "superjson";
|
||||
|
||||
class VirtualServiceManager {
|
||||
private services: Map<string, any> = new Map();
|
||||
private eventWrapper: NTEventWrapper;
|
||||
|
||||
constructor(eventWrapper: NTEventWrapper) {
|
||||
this.eventWrapper = eventWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建虚拟服务实例
|
||||
*/
|
||||
private createVirtualService<T extends keyof import("@/core/services").ServiceNamingMapping>(
|
||||
serviceName: T
|
||||
): import("@/core/services").ServiceNamingMapping[T] {
|
||||
if (this.services.has(serviceName)) {
|
||||
return this.services.get(serviceName);
|
||||
}
|
||||
|
||||
const serviceClient = createVirtualServiceClient(serviceName, async (serviceCommand, ...args) => {
|
||||
const call_dto = superjson.stringify({ command: serviceCommand, params: args });
|
||||
const call_data = superjson.parse<{ command: ServiceMethodCommand; params: any[] }>(call_dto);
|
||||
|
||||
return handleServiceServerOnce(
|
||||
call_data.command,
|
||||
async (listenerCommand: string, ...args: any[]) => {
|
||||
const listener_dto = superjson.stringify({ command: listenerCommand, params: args });
|
||||
const listener_data = superjson.parse<{ command: string; params: any[] }>(listener_dto);
|
||||
serviceClient.receiverListener(listener_data.command, ...listener_data.params);
|
||||
},
|
||||
this.eventWrapper,
|
||||
...call_data.params
|
||||
);
|
||||
});
|
||||
|
||||
this.services.set(serviceName, serviceClient.object);
|
||||
return serviceClient.object;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建服务实例
|
||||
*/
|
||||
getService<T extends keyof import("@/core/services").ServiceNamingMapping>(
|
||||
serviceName: T
|
||||
): import("@/core/services").ServiceNamingMapping[T] {
|
||||
return this.createVirtualService(serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* NodeIQQNTWrapperSession 的行为
|
||||
*/
|
||||
export class VirtualWrapperSession implements NodeIQQNTWrapperSession {
|
||||
private serviceManager: VirtualServiceManager;
|
||||
|
||||
constructor(eventWrapper: NTEventWrapper) {
|
||||
this.serviceManager = new VirtualServiceManager(eventWrapper);
|
||||
}
|
||||
|
||||
create(): NodeIQQNTWrapperSession {
|
||||
return new VirtualWrapperSession(this.serviceManager['eventWrapper']);
|
||||
}
|
||||
|
||||
init(
|
||||
_wrapperSessionInitConfig: WrapperSessionInitConfig,
|
||||
_nodeIDependsAdapter: NodeIDependsAdapter,
|
||||
_nodeIDispatcherAdapter: NodeIDispatcherAdapter,
|
||||
_nodeIKernelSessionListener: NodeIKernelSessionListener,
|
||||
): void {
|
||||
}
|
||||
|
||||
startNT(_session?: number): void {
|
||||
}
|
||||
getBdhUploadService() { return null; }
|
||||
getECDHService() { return this.serviceManager.getService('NodeIKernelECDHService'); }
|
||||
getMsgService() { return this.serviceManager.getService('NodeIKernelMsgService'); }
|
||||
getProfileService() { return this.serviceManager.getService('NodeIKernelProfileService'); }
|
||||
getProfileLikeService() { return this.serviceManager.getService('NodeIKernelProfileLikeService'); }
|
||||
getGroupService() { return this.serviceManager.getService('NodeIKernelGroupService'); }
|
||||
getStorageCleanService() { return this.serviceManager.getService('NodeIKernelStorageCleanService'); }
|
||||
getBuddyService() { return this.serviceManager.getService('NodeIKernelBuddyService'); }
|
||||
getRobotService() { return this.serviceManager.getService('NodeIKernelRobotService'); }
|
||||
getTicketService() { return this.serviceManager.getService('NodeIKernelTicketService'); }
|
||||
getTipOffService() { return this.serviceManager.getService('NodeIKernelTipOffService'); }
|
||||
getNodeMiscService() { return this.serviceManager.getService('NodeIKernelNodeMiscService'); }
|
||||
getRichMediaService() { return this.serviceManager.getService('NodeIKernelRichMediaService'); }
|
||||
getMsgBackupService() { return this.serviceManager.getService('NodeIKernelMsgBackupService'); }
|
||||
getAlbumService() { return this.serviceManager.getService('NodeIKernelAlbumService'); }
|
||||
getTianShuService() { return this.serviceManager.getService('NodeIKernelTianShuService'); }
|
||||
getUnitedConfigService() { return this.serviceManager.getService('NodeIKernelUnitedConfigService'); }
|
||||
getSearchService() { return this.serviceManager.getService('NodeIKernelSearchService'); }
|
||||
getDirectSessionService() { return null; }
|
||||
getRDeliveryService() { return null; }
|
||||
getAvatarService() { return this.serviceManager.getService('NodeIKernelAvatarService'); }
|
||||
getFeedChannelService() { return null; }
|
||||
getYellowFaceService() { return null; }
|
||||
getCollectionService() { return this.serviceManager.getService('NodeIKernelCollectionService'); }
|
||||
getSettingService() { return null; }
|
||||
getQiDianService() { return null; }
|
||||
getFileAssistantService() { return this.serviceManager.getService('NodeIKernelFileAssistantService'); }
|
||||
getGuildService() { return null; }
|
||||
getSkinService() { return null; }
|
||||
getTestPerformanceService() { return this.serviceManager.getService('NodeIkernelTestPerformanceService'); }
|
||||
getQQPlayService() { return null; }
|
||||
getDbToolsService() { return this.serviceManager.getService('NodeIKernelDbToolsService'); }
|
||||
getUixConvertService() { return this.serviceManager.getService('NodeIKernelUixConvertService'); }
|
||||
getOnlineStatusService() { return this.serviceManager.getService('NodeIKernelOnlineStatusService'); }
|
||||
getRemotingService() { return null; }
|
||||
getGroupTabService() { return null; }
|
||||
getGroupSchoolService() { return null; }
|
||||
getLiteBusinessService() { return null; }
|
||||
getGuildMsgService() { return null; }
|
||||
getLockService() { return null; }
|
||||
getMSFService() { return this.serviceManager.getService('NodeIKernelMSFService'); }
|
||||
getGuildHotUpdateService() { return null; }
|
||||
getAVSDKService() { return null; }
|
||||
getRecentContactService() { return this.serviceManager.getService('NodeIKernelRecentContactService'); }
|
||||
getConfigMgrService() { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建完全虚拟的QQ NT会话
|
||||
* @param eventWrapper 事件包装器
|
||||
* @returns 虚拟会话实例
|
||||
*/
|
||||
export function createVirtualSession(eventWrapper: NTEventWrapper): NodeIQQNTWrapperSession {
|
||||
return new VirtualWrapperSession(eventWrapper) as NodeIQQNTWrapperSession;
|
||||
}
|
@@ -1,17 +1,16 @@
|
||||
import { FuncKeys, NTEventWrapper } from "@/common/event";
|
||||
import { ServiceNamingMapping } from "@/core";
|
||||
|
||||
type ServiceMethodCommand = {
|
||||
export type ServiceMethodCommand = {
|
||||
[Service in keyof ServiceNamingMapping]: `${Service}/${FuncKeys<ServiceNamingMapping[Service]>}`
|
||||
}[keyof ServiceNamingMapping];
|
||||
|
||||
export const RegisterListenerCmd: Array<ServiceMethodCommand> = [
|
||||
'NodeIKernelMsgService/addKernelMsgListener',
|
||||
'NodeIKernelGroupService/addKernelGroupListener',
|
||||
'NodeIKernelProfileLikeService/addKernelProfileLikeListener',
|
||||
'NodeIKernelProfileService/addKernelProfileListener',
|
||||
'NodeIKernelBuddyService/addKernelBuddyListener',
|
||||
];
|
||||
// 使用正则表达式匹配监听器注册命令
|
||||
const LISTENER_COMMAND_PATTERN = /\/addKernel\w*Listener$/;
|
||||
|
||||
function isListenerCommand(command: ServiceMethodCommand): boolean {
|
||||
return LISTENER_COMMAND_PATTERN.test(command);
|
||||
}
|
||||
|
||||
export function createVirtualServiceServer<T extends keyof ServiceNamingMapping>(
|
||||
serviceName: T,
|
||||
@@ -21,7 +20,7 @@ export function createVirtualServiceServer<T extends keyof ServiceNamingMapping>
|
||||
return new Proxy(() => { }, {
|
||||
get: (_target: any, functionName: string) => {
|
||||
const command = `${serviceName}/${functionName}` as ServiceMethodCommand;
|
||||
if (RegisterListenerCmd.includes(command as ServiceMethodCommand)) {
|
||||
if (isListenerCommand(command)) {
|
||||
return async (..._args: any[]) => {
|
||||
const listener = new Proxy(new class { }(), {
|
||||
apply: (_target, _thisArg, _arguments) => {
|
||||
@@ -40,14 +39,14 @@ export function createVirtualServiceServer<T extends keyof ServiceNamingMapping>
|
||||
|
||||
// 问题2: 全局状态管理可能导致内存泄漏和状态污染
|
||||
export const listenerCmdRegisted = new Map<ServiceMethodCommand, boolean>();
|
||||
export const clientCallback = new Map<string, (command: string, ...args: any[]) => Promise<any>>();
|
||||
export const clientCallback = new Map<string, (...args: any[]) => Promise<any>>();
|
||||
export async function handleServiceServerOnce(
|
||||
command: ServiceMethodCommand,// 服务注册命令
|
||||
recvListener: (command: string, ...args: any[]) => Promise<any>,//listener监听器
|
||||
ntevent: NTEventWrapper,// 事件处理器
|
||||
...args: any[]//实际参数
|
||||
) {
|
||||
if (RegisterListenerCmd.includes(command)) {
|
||||
if (isListenerCommand(command)) {
|
||||
if (!listenerCmdRegisted.has(command)) {
|
||||
listenerCmdRegisted.set(command, true);
|
||||
return (ntevent.callNoListenerEvent as any)(command, new Proxy(new class { }(), {
|
||||
@@ -61,8 +60,6 @@ export async function handleServiceServerOnce(
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
console.log('handleServiceServerOnce', command, 'args', args);
|
||||
console.log('params', args);
|
||||
return await (ntevent.callNoListenerEvent as (command: ServiceMethodCommand, ...args: any[]) => Promise<any>)(command, ...args);
|
||||
}
|
||||
|
||||
@@ -73,7 +70,7 @@ export function createVirtualServiceClient<T extends keyof ServiceNamingMapping>
|
||||
const object = new Proxy(() => { }, {
|
||||
get: (_target: any, functionName: string) => {
|
||||
const command = `${serviceName}/${functionName}` as ServiceMethodCommand;
|
||||
if (RegisterListenerCmd.includes(command as ServiceMethodCommand)) {
|
||||
if (isListenerCommand(command)) {
|
||||
if (!clientCallback.has(command)) {
|
||||
return async (listener: Record<string, any>) => {
|
||||
// 遍历 listener
|
||||
@@ -94,12 +91,15 @@ export function createVirtualServiceClient<T extends keyof ServiceNamingMapping>
|
||||
});
|
||||
|
||||
const receiverListener = function (command: string, ...args: any[]) {
|
||||
return clientCallback.get(command)?.(command, ...args);
|
||||
if (command.indexOf('onRecvMsg') !== - 1 || command.indexOf('onRecvSysMsg') !== -1) {
|
||||
console.log(`Received command: ${command}, with args: ${JSON.stringify(args)}`);
|
||||
}
|
||||
return clientCallback.get(command)?.(...args);
|
||||
};
|
||||
return { receiverListener: receiverListener, object: object as ServiceNamingMapping[T] };
|
||||
}
|
||||
|
||||
// 建议添加清理函数
|
||||
|
||||
export function clearServiceState() {
|
||||
listenerCmdRegisted.clear();
|
||||
clientCallback.clear();
|
||||
|
@@ -1,82 +0,0 @@
|
||||
# QRCode Terminal Edition [![Build Status][travis-ci-img]][travis-ci-url]
|
||||
|
||||
> Going where no QRCode has gone before.
|
||||
|
||||
![Basic Example][basic-example-img]
|
||||
|
||||
# Node Library
|
||||
|
||||
## Install
|
||||
|
||||
Can be installed with:
|
||||
|
||||
$ npm install qrcode-terminal
|
||||
|
||||
and used:
|
||||
|
||||
var qrcode = require('qrcode-terminal');
|
||||
|
||||
## Usage
|
||||
|
||||
To display some data to the terminal just call:
|
||||
|
||||
qrcode.generate('This will be a QRCode, eh!');
|
||||
|
||||
You can even specify the error level (default is 'L'):
|
||||
|
||||
qrcode.setErrorLevel('Q');
|
||||
qrcode.generate('This will be a QRCode with error level Q!');
|
||||
|
||||
If you don't want to display to the terminal but just want to string you can provide a callback:
|
||||
|
||||
qrcode.generate('http://github.com', function (qrcode) {
|
||||
console.log(qrcode);
|
||||
});
|
||||
|
||||
If you want to display small output, provide `opts` with `small`:
|
||||
|
||||
qrcode.generate('This will be a small QRCode, eh!', {small: true});
|
||||
|
||||
qrcode.generate('This will be a small QRCode, eh!', {small: true}, function (qrcode) {
|
||||
console.log(qrcode)
|
||||
});
|
||||
|
||||
# Command-Line
|
||||
|
||||
## Install
|
||||
|
||||
$ npm install -g qrcode-terminal
|
||||
|
||||
## Usage
|
||||
|
||||
$ qrcode-terminal --help
|
||||
$ qrcode-terminal 'http://github.com'
|
||||
$ echo 'http://github.com' | qrcode-terminal
|
||||
|
||||
# Support
|
||||
|
||||
- OS X
|
||||
- Linux
|
||||
- Windows
|
||||
|
||||
# Server-side
|
||||
|
||||
[node-qrcode][node-qrcode-url] is a popular server-side QRCode generator that
|
||||
renders to a `canvas` object.
|
||||
|
||||
# Developing
|
||||
|
||||
To setup the development envrionment run `npm install`
|
||||
|
||||
To run tests run `npm test`
|
||||
|
||||
# Contributers
|
||||
|
||||
Gord Tanner <gtanner@gmail.com>
|
||||
Micheal Brooks <michael@michaelbrooks.ca>
|
||||
|
||||
[travis-ci-img]: https://travis-ci.org/gtanner/qrcode-terminal.png
|
||||
[travis-ci-url]: https://travis-ci.org/gtanner/qrcode-terminal
|
||||
[basic-example-img]: https://raw.github.com/gtanner/qrcode-terminal/master/example/basic.png
|
||||
[node-qrcode-url]: https://github.com/soldair/node-qrcode
|
||||
|
Reference in New Issue
Block a user