feat: 开放平台机器人接口

pull/56/head
moonrailgun 3 years ago
parent ec84f664ea
commit f038d08af8

@ -17,6 +17,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"axios": "^0.21.1",
"probot": "^12.2.4" "probot": "^12.2.4"
}, },
"devDependencies": { "devDependencies": {

@ -1,9 +1,25 @@
import { Probot } from 'probot'; import { Probot } from 'probot';
import { TailchatClient } from './client';
// const tailchatApiUrl = process.env.TAILCHAT_API_URL;
const configPath = '.tailchat/topic.json'; const configPath = '.tailchat/topic.json';
export function app(app: Probot) { export function app(app: Probot) {
if (
!process.env.TAILCHAT_API_URL ||
!process.env.TAILCHAT_APP_ID ||
!process.env.TAILCHAT_APP_SECRET
) {
throw new Error(
'Require env: TAILCHAT_API_URL, TAILCHAT_APP_ID, TAILCHAT_APP_SECRET'
);
}
const tailchatClient = new TailchatClient(
process.env.TAILCHAT_API_URL,
process.env.TAILCHAT_APP_ID,
process.env.TAILCHAT_APP_SECRET
);
app.on('issues.opened', async (ctx) => { app.on('issues.opened', async (ctx) => {
if (ctx.isBot) { if (ctx.isBot) {
return; return;

@ -0,0 +1,71 @@
import axios, { AxiosInstance } from 'axios';
import crypto from 'crypto';
export class TailchatClient {
request: AxiosInstance;
jwt: string | null = null;
constructor(
public url: string,
public appId: string,
public appSecret: string
) {
this.request = axios.create({
baseURL: url,
});
this.request.interceptors.request.use(async (val) => {
if (
this.jwt &&
['post', 'get'].includes(String(val.method).toLowerCase()) &&
!val.headers['X-Token']
) {
// 任何请求都尝试增加token
val.headers['X-Token'] = this.jwt;
}
return val;
});
this.login();
}
async login() {
try {
const { data } = await this.request.post('/api/openapi/bot/login', {
appId: this.appId,
token: this.getBotToken(),
});
// NOTICE: 注意有30天过期时间需要定期重新登录以换取新的token
// 这里先不换
this.jwt = data.data?.jwt;
console.log('tailchat openapp login success!');
// 尝试调用函数
console.log(await this.whoami());
} catch (err) {
console.error(err);
throw new Error();
}
}
async call(action: string, params = {}) {
const { data } = await this.request.post(
'/api/' + action.replace(/\./g, '/'),
params
);
return data;
}
async whoami() {
return this.call('user.whoami');
}
getBotToken() {
return crypto
.createHash('md5')
.update(this.appId + this.appSecret)
.digest('hex');
}
}

@ -60,6 +60,7 @@ importers:
specifiers: specifiers:
'@types/jest': ^28.1.0 '@types/jest': ^28.1.0
'@types/node': ^18.0.0 '@types/node': ^18.0.0
axios: ^0.21.1
jest: ^26.6.3 jest: ^26.6.3
nock: ^13.0.5 nock: ^13.0.5
nodemon: ^2.0.18 nodemon: ^2.0.18
@ -69,6 +70,7 @@ importers:
ts-node: ^10.9.1 ts-node: ^10.9.1
typescript: ^4.1.3 typescript: ^4.1.3
dependencies: dependencies:
axios: 0.21.4
probot: 12.2.8 probot: 12.2.8
devDependencies: devDependencies:
'@types/jest': 28.1.8 '@types/jest': 28.1.8

@ -31,7 +31,7 @@ export class User {
avatar!: boolean; avatar!: boolean;
@Column({ @Column({
enum: ['normalUser', 'pluginBot', 'thirdpartyBot'], enum: ['normalUser', 'pluginBot', 'openapiBot'],
default: 'normalUser', default: 'normalUser',
}) })
type: string; type: string;

@ -11,7 +11,7 @@ import type { Types } from 'mongoose';
type BaseUserInfo = Pick<User, 'nickname' | 'discriminator' | 'avatar'>; type BaseUserInfo = Pick<User, 'nickname' | 'discriminator' | 'avatar'>;
const userType = ['normalUser', 'pluginBot', 'thirdpartyBot']; const userType = ['normalUser', 'pluginBot', 'openapiBot'];
type UserType = typeof userType[number]; type UserType = typeof userType[number];
/** /**

@ -1,4 +1,4 @@
const userType = ['normalUser', 'pluginBot', 'thirdpartyBot']; const userType = ['normalUser', 'pluginBot', 'openapiBot'];
type UserType = typeof userType[number]; type UserType = typeof userType[number];
export interface UserStruct { export interface UserStruct {

@ -27,6 +27,10 @@ const Root = styled(LoadingOnFirst)({
width: '100%', width: '100%',
position: 'relative', position: 'relative',
'.ant-empty': {
paddingTop: 80,
},
'.create-btn': { '.create-btn': {
position: 'absolute', position: 'absolute',
right: 20, right: 20,

@ -173,6 +173,25 @@ class UserService extends TcService {
avatar: { type: 'string', optional: true }, avatar: { type: 'string', optional: true },
}, },
}); });
this.registerAction('ensureOpenapiBot', this.ensureOpenapiBot, {
params: {
/**
* id, <botId>@tailchat-open.com
*/
botId: 'string',
nickname: 'string',
avatar: { type: 'string', optional: true },
},
});
this.registerAction('generateUserToken', this.generateUserToken, {
params: {
userId: 'string',
nickname: 'string',
email: 'string',
avatar: 'string',
},
visibility: 'public',
});
this.registerAuthWhitelist(['/user/forgetPassword', '/user/resetPassword']); this.registerAuthWhitelist(['/user/forgetPassword', '/user/resetPassword']);
} }
@ -644,6 +663,88 @@ class UserService extends TcService {
return String(newBot._id); return String(newBot._id);
} }
/**
*
*/
async ensureOpenapiBot(
ctx: TcContext<{
botId: string;
nickname: string;
avatar: string;
}>
): Promise<{
_id: string;
email: string;
nickname: string;
avatar: string;
}> {
const { botId, nickname, avatar } = ctx.params;
const email = this.buildOpenapiBotEmail(botId);
const bot = await this.adapter.model.findOne({
email,
});
if (bot) {
if (bot.nickname !== nickname || bot.avatar !== avatar) {
/**
*
*/
this.logger.info('检查到第三方机器人信息不匹配, 更新机器人信息:', {
nickname,
avatar,
});
await bot.updateOne({
nickname,
avatar,
});
await this.cleanUserInfoCache(String(bot._id));
}
return {
_id: String(bot._id),
email,
nickname,
avatar,
};
}
// 如果不存在,则创建
const newBot = await this.adapter.model.create({
email,
nickname,
avatar,
type: 'openapiBot',
});
return {
_id: String(newBot._id),
email,
nickname,
avatar,
};
}
async generateUserToken(
ctx: TcContext<{
userId: string;
nickname: string;
email: string;
avatar: string;
}>
) {
const { userId, nickname, email, avatar } = ctx.params;
const token = this.generateJWT({
_id: userId,
nickname,
email,
avatar,
});
return token;
}
/** /**
* *
*/ */
@ -763,6 +864,10 @@ class UserService extends TcService {
private buildPluginBotEmail(botId: string) { private buildPluginBotEmail(botId: string) {
return `${botId}@tailchat-plugin.com`; return `${botId}@tailchat-plugin.com`;
} }
private buildOpenapiBotEmail(botId: string) {
return `${botId}@tailchat-openapi.com`;
}
} }
export default UserService; export default UserService;

@ -13,6 +13,7 @@ import {
} from '../../models/openapi/app'; } from '../../models/openapi/app';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import crypto from 'crypto';
interface OpenAppService interface OpenAppService
extends TcService, extends TcService,
@ -29,7 +30,23 @@ class OpenAppService extends TcService {
this.registerLocalDb(require('../../models/openapi/app').default); this.registerLocalDb(require('../../models/openapi/app').default);
this.registerAction('authToken', this.authToken, {
params: {
appId: 'string',
token: 'string',
capability: { type: 'array', items: 'string', optional: true },
},
cache: {
keys: ['appId', 'token'],
ttl: 60 * 60, // 1 hour
},
});
this.registerAction('all', this.all); this.registerAction('all', this.all);
this.registerAction('get', this.get, {
params: {
appId: 'string',
},
});
this.registerAction('create', this.create, { this.registerAction('create', this.create, {
params: { params: {
appName: 'string', appName: 'string',
@ -52,6 +69,51 @@ class OpenAppService extends TcService {
}); });
} }
/**
* Token true/false
*
* Token : appId + appSecret md5
*/
async authToken(
ctx: TcContext<{
appId: string;
token: string;
capability?: OpenAppDocument['capability'];
}>
): Promise<boolean> {
const { appId, token, capability } = ctx.params;
const app = await this.adapter.model.findOne({
appId,
});
if (!app) {
// 没有找到应用
throw new Error('Not found open app:' + appId);
}
if (Array.isArray(capability)) {
for (const item of capability) {
if (!app.capability.includes(item)) {
throw new Error('Open app not enabled capability:' + item);
}
}
}
const appSecret = app.appSecret;
if (
token ===
crypto
.createHash('md5')
.update(appId + appSecret)
.digest('hex')
) {
return true;
}
return false;
}
/** /**
* *
*/ */
@ -63,6 +125,24 @@ class OpenAppService extends TcService {
return await this.transformDocuments(ctx, {}, apps); return await this.transformDocuments(ctx, {}, apps);
} }
/**
*
*/
async get(ctx: TcContext<{ appId: string }>) {
const appId = ctx.params.appId;
const apps = await this.adapter.model.findOne(
{
appId,
},
{
appSecret: false,
}
);
return await this.transformDocuments(ctx, {}, apps);
}
/** /**
* *
*/ */

@ -0,0 +1,118 @@
import { TcService, config, TcContext } from 'tailchat-server-sdk';
import type { OpenApp } from '../../models/openapi/app';
class OpenBotService extends TcService {
get serviceName(): string {
return 'openapi.bot';
}
onInit(): void {
if (!config.enableOpenapi) {
return;
}
this.registerAction('login', this.login, {
params: {
appId: 'string',
token: 'string',
},
});
this.registerAction('getOrCreateBotAccount', this.getOrCreateBotAccount, {
params: {
appId: 'string',
},
visibility: 'private',
});
this.registerAuthWhitelist(['/openapi/bot/login']);
}
/**
*
*
*
*/
async login(ctx: TcContext<{ appId: string; token: string }>) {
const { appId, token } = ctx.params;
const valid = await ctx.call('openapi.app.authToken', {
appId,
token,
capability: ['bot'],
});
if (!valid) {
throw new Error('auth failed.');
}
// 校验通过, 获取机器人账号存在
const { userId, email, nickname, avatar } = await this.localCall(
'getOrCreateBotAccount',
{
appId,
}
);
const jwt: string = await ctx.call('user.generateUserToken', {
userId,
email,
nickname,
avatar,
});
return { jwt };
}
/**
*
*/
async getOrCreateBotAccount(ctx: TcContext<{ appId: string }>): Promise<{
userId: string;
email: string;
nickname: string;
avatar: string;
}> {
const appId = ctx.params.appId;
await this.waitForServices(['user']);
const appInfo: OpenApp = await ctx.call('openapi.app.get', {
appId,
});
try {
const botId = 'open_' + appInfo._id;
const nickname = appInfo.appName;
const avatar = appInfo.appIcon;
console.log('da', {
botId,
nickname,
avatar,
appInfo,
});
const { _id: botUserId, email } = await ctx.call<
{
_id: string;
email: string;
},
any
>('user.ensureOpenapiBot', {
botId,
nickname,
avatar,
});
this.logger.info('Simple Notify Bot Id:', botUserId);
return {
userId: String(botUserId),
email,
nickname,
avatar,
};
} catch (e) {
this.logger.error(e);
throw e;
}
}
}
export default OpenBotService;
Loading…
Cancel
Save