From f038d08af8bf72833f105acbd976e21c046af706 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sat, 15 Oct 2022 00:31:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=80=E6=94=BE=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/github-app/package.json | 1 + apps/github-app/src/app.ts | 18 ++- apps/github-app/src/client.ts | 71 +++++++++++ pnpm-lock.yaml | 2 + server/admin/src/resources/User.ts | 2 +- server/models/user/user.ts | 2 +- server/packages/sdk/src/structs/user.ts | 2 +- .../src/group/GroupTopicPanelRender.tsx | 4 + server/services/core/user/user.service.ts | 105 ++++++++++++++++ server/services/openapi/app.service.ts | 80 ++++++++++++ server/services/openapi/bot.service.ts | 118 ++++++++++++++++++ 11 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 apps/github-app/src/client.ts create mode 100644 server/services/openapi/bot.service.ts diff --git a/apps/github-app/package.json b/apps/github-app/package.json index 9f71c148..ebffc4f4 100644 --- a/apps/github-app/package.json +++ b/apps/github-app/package.json @@ -17,6 +17,7 @@ "test": "jest" }, "dependencies": { + "axios": "^0.21.1", "probot": "^12.2.4" }, "devDependencies": { diff --git a/apps/github-app/src/app.ts b/apps/github-app/src/app.ts index 8f87a71f..afab7eb9 100644 --- a/apps/github-app/src/app.ts +++ b/apps/github-app/src/app.ts @@ -1,9 +1,25 @@ import { Probot } from 'probot'; +import { TailchatClient } from './client'; -// const tailchatApiUrl = process.env.TAILCHAT_API_URL; const configPath = '.tailchat/topic.json'; 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) => { if (ctx.isBot) { return; diff --git a/apps/github-app/src/client.ts b/apps/github-app/src/client.ts new file mode 100644 index 00000000..da4abecf --- /dev/null +++ b/apps/github-app/src/client.ts @@ -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'); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fd31e2e..0cba6807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,7 @@ importers: specifiers: '@types/jest': ^28.1.0 '@types/node': ^18.0.0 + axios: ^0.21.1 jest: ^26.6.3 nock: ^13.0.5 nodemon: ^2.0.18 @@ -69,6 +70,7 @@ importers: ts-node: ^10.9.1 typescript: ^4.1.3 dependencies: + axios: 0.21.4 probot: 12.2.8 devDependencies: '@types/jest': 28.1.8 diff --git a/server/admin/src/resources/User.ts b/server/admin/src/resources/User.ts index c09f8284..a034a942 100644 --- a/server/admin/src/resources/User.ts +++ b/server/admin/src/resources/User.ts @@ -31,7 +31,7 @@ export class User { avatar!: boolean; @Column({ - enum: ['normalUser', 'pluginBot', 'thirdpartyBot'], + enum: ['normalUser', 'pluginBot', 'openapiBot'], default: 'normalUser', }) type: string; diff --git a/server/models/user/user.ts b/server/models/user/user.ts index 0f3ab1b8..db5371c8 100644 --- a/server/models/user/user.ts +++ b/server/models/user/user.ts @@ -11,7 +11,7 @@ import type { Types } from 'mongoose'; type BaseUserInfo = Pick; -const userType = ['normalUser', 'pluginBot', 'thirdpartyBot']; +const userType = ['normalUser', 'pluginBot', 'openapiBot']; type UserType = typeof userType[number]; /** diff --git a/server/packages/sdk/src/structs/user.ts b/server/packages/sdk/src/structs/user.ts index 12b2dd31..c7895c80 100644 --- a/server/packages/sdk/src/structs/user.ts +++ b/server/packages/sdk/src/structs/user.ts @@ -1,4 +1,4 @@ -const userType = ['normalUser', 'pluginBot', 'thirdpartyBot']; +const userType = ['normalUser', 'pluginBot', 'openapiBot']; type UserType = typeof userType[number]; export interface UserStruct { diff --git a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx index c03124c8..b27bd4d0 100644 --- a/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx +++ b/server/plugins/com.msgbyte.topic/web/plugins/com.msgbyte.topic/src/group/GroupTopicPanelRender.tsx @@ -27,6 +27,10 @@ const Root = styled(LoadingOnFirst)({ width: '100%', position: 'relative', + '.ant-empty': { + paddingTop: 80, + }, + '.create-btn': { position: 'absolute', right: 20, diff --git a/server/services/core/user/user.service.ts b/server/services/core/user/user.service.ts index 96e8e1bc..0b57c449 100644 --- a/server/services/core/user/user.service.ts +++ b/server/services/core/user/user.service.ts @@ -173,6 +173,25 @@ class UserService extends TcService { avatar: { type: 'string', optional: true }, }, }); + this.registerAction('ensureOpenapiBot', this.ensureOpenapiBot, { + params: { + /** + * 用户名唯一id, 创建的用户邮箱会为 @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']); } @@ -644,6 +663,88 @@ class UserService extends TcService { 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) { return `${botId}@tailchat-plugin.com`; } + + private buildOpenapiBotEmail(botId: string) { + return `${botId}@tailchat-openapi.com`; + } } export default UserService; diff --git a/server/services/openapi/app.service.ts b/server/services/openapi/app.service.ts index 2bc9f5b7..d437f65d 100644 --- a/server/services/openapi/app.service.ts +++ b/server/services/openapi/app.service.ts @@ -13,6 +13,7 @@ import { } from '../../models/openapi/app'; import { Types } from 'mongoose'; import { nanoid } from 'nanoid'; +import crypto from 'crypto'; interface OpenAppService extends TcService, @@ -29,7 +30,23 @@ class OpenAppService extends TcService { 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('get', this.get, { + params: { + appId: 'string', + }, + }); this.registerAction('create', this.create, { params: { 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 { + 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); } + /** + * 获取应用信息 + */ + 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); + } + /** * 创建一个第三方应用 */ diff --git a/server/services/openapi/bot.service.ts b/server/services/openapi/bot.service.ts new file mode 100644 index 00000000..7c2fa8f9 --- /dev/null +++ b/server/services/openapi/bot.service.ts @@ -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;