diff --git a/packages/bot/package.json b/packages/bot/package.json new file mode 100644 index 0000000..178328d --- /dev/null +++ b/packages/bot/package.json @@ -0,0 +1,40 @@ +{ + "name": "@satorijs/adapter-im", + "description": "Bot of Satori IM", + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "files": [ + "lib", + "dist" + ], + "author": "Shigma ", + "license": "MIT", + "scripts": { + "lint": "eslint src --ext .ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/koishijs/koishi-plugin-im.git", + "directory": "packages/bot" + }, + "bugs": { + "url": "https://github.com/koishijs/koishi-plugin-im/issues" + }, + "keywords": [ + "satori", + "plugin", + "chat", + "im", + "adapter" + ], + "peerDependencies": { + "@satorijs/core": "^4.2.6", + "@satorijs/plugin-im": "^0.0.0" + }, + "devDependencies": { + "@cordisjs/client": "^0.1.12", + "@satorijs/core": "^4.2.6", + "@satorijs/plugin-im": "^0.0.0" + } +} diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts new file mode 100644 index 0000000..8710d12 --- /dev/null +++ b/packages/bot/src/bot.ts @@ -0,0 +1,217 @@ +import { Bot, Context, Fragment, h, Schema, Universal } from '@satorijs/core' +import { SendOptions } from '@satorijs/protocol' +import { Im } from '@satorijs/plugin-im' + +// TODO: implement ImMessageDecoder. +export class ImBot extends Bot { + static inject = ['im.data', 'im.auth', 'im'] + + private login: Im.Login | null = null + + async getUser(userId: string) { + return this.ctx['im.data'].user.fetch(this.login!, userId) + } + + async getLogin(): Promise { + if (this.config.type === 'token') { + this.login = await this.ctx['im.auth'].authenticateWithToken({ + token: this.config.token, + } as any) + } else { + this.login = await this.ctx['im.auth'].authenticate( + this.config.name, + this.config.password, + this.selfId + ) + } + + this.user = this.login?.user! + + return this.login! + } + + async dispose() { + await this.ctx['im.auth'].logout(this.login) + } + + async getGuild(guildId: string) { + const data = await this.ctx['im.data'].guild.fetch(this.login!, guildId) + return data + } + + async getGuildList() { + const data = await this.ctx['im.data'].guild.list(this.login!) + return { + data, + } + } + + async getChannel(channelId: string) { + return this.ctx['im.data'].channel.fetch(this.login!, channelId) + } + + async getGuildMember(guildId: string, userId: string) { + return this.ctx['im.data'].guild.Member.fetch(this.login!, guildId, userId) + } + + async getGuildMemberList(guildId: string) { + const data = await this.ctx['im.data'].guild.Member.list(this.login!, guildId) + return { + data, + } + } + + async createChannel( + guildId: string, + data: Partial + ): Promise { + if (!data.name) throw new Error('name is required.') + return this.ctx['im.data'].channel.create(this.login!, guildId, data.name) + } + + async updateChannel(guildId: string, data: Partial) { + await this.ctx['im.data'].channel.update(this.login!, guildId, data.name) + } + + async deleteChannel(channelId: string) { + await this.ctx['im.data'].channel.softDel(this.login!, channelId) + } + + async muteChannel(channelId: string) { + await this.ctx['im.data'].channel.updateSettings(this.login, channelId, { + level: Im.NotifyLevels.BLOCKED, + }) + } + + async handleFriendRequest(messageId: string, approve: boolean, comment?: string) { + await this.ctx['im.data'].notification.reply(this.login!, messageId, approve) + } + + async handleGuildMemberRequest(messageId: string, approve: boolean, comment?: string) { + await this.ctx['im.data'].notification.reply(this.login!, messageId, approve) + } + + async kickGuildMember(guildId: string, userId: string) { + await this.ctx['im.data'].guild.Member.kick(this.login!, guildId, userId) + } + + // TODO: + async getGuildRoleList(guildId: string, next?: string) { + const data = await this.ctx['im.data'].guild.Role.list(this.login!, guildId) + + return { + data, + } + } + + async createGuildRole(guildId: string, data: Partial) { + if (!data.name || !data.color) throw new Error('') + const res = await this.ctx['im.data'].guild.Role.create( + this.login!, + guildId, + data.name!, + data.color! + ) + return res + } + + async updateGuildRole(guildId: string, roleId: string, data: Partial) {} + + async deleteGuildRole(guildId: string, roleId: string) { + await this.ctx['im.data'].guild.Role.hardDel(this.login!, guildId, roleId) + } + + setGuildMemberRole(guildId: string, userId: string, roleId: string) { + return this.ctx['im.data'].guild.Role.set(this.login!, roleId, userId) + } + + unsetGuildMemberRole(guildId: string, userId: string, roleId: string) { + return this.ctx['im.data'].guild.Role.unset(this.login!, roleId, userId) + } + + // TODO: + async getMessageList( + channelId: string, + messageId?: string, + direction: Universal.Direction = 'before', + limit?: number, + order: Universal.Order = 'asc' + ) { + const messages = await this.ctx['im.data'].message.fetch(this.login!, channelId) + return messages + } + + // TODO: + async sendMessage( + channelId: string, + content: h.Fragment, + guildId?: string, + options?: SendOptions + ) { + let guild: Im.Guild | undefined = guildId ? { id: guildId } : undefined + await this.ctx['im.data'].message.create(this.login!, { + content: content as string, + guild, + channel: { id: channelId } as Im.Channel, + }) + return [] + } + + // HACK: No use of guildId. + async sendPrivateMessage( + userId: string, + content: h.Fragment, + guildId?: string, + options?: SendOptions + ) { + const channel = (await this.ctx['im.data'].friend.fetch(this.login!, userId))?.channel + if (!channel) { + throw new Error('no permission to send private message to selected user.') + } + await this.ctx['im.data'].message.create(this.login!, { + content: content as string, + channel: { id: channel.id } as Im.Channel, + }) + + return [] + } + + // TODO: how we resolve the satori element to im message? + async editMessage(channelId: string, messageId: string, content: Fragment) { + await this.ctx['im.data'].message.update(this.login!, channelId, messageId, content as string) + } + + async deleteMessage(channelId: string, messageId: string) { + await this.ctx['im.data'].message.softDel(this.login!, channelId, messageId) + } + + async updateCommands(commands: Universal.Command[]): Promise { + await this.ctx['im.data'].bot._updateCommands(commands) + } +} + +export namespace ImBot { + export interface Config { + type: 'token' | 'password' + name: string + password: string + token: string + } + + export const Config: Schema = Schema.intersect([ + Schema.object({ + type: Schema.union(['token', 'password']).default('password').description('登录方式'), + }), + Schema.union([ + Schema.object({ + type: Schema.const('token').required(), + token: Schema.string().description('Bot 用户令牌。').role('secret').required(), + }), + Schema.object({ + type: Schema.const('password'), + name: Schema.string().description('Bot 用户名称').required(), + password: Schema.string().description('Bot 登录所用的密码').role('secret').required(), + }), + ]), + ]) as any +} diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts new file mode 100644 index 0000000..00421a1 --- /dev/null +++ b/packages/bot/src/index.ts @@ -0,0 +1,4 @@ +import {} from '@satorijs/plugin-im' +import { ImBot } from './bot' + +export default ImBot diff --git a/packages/bot/tsconfig.json b/packages/bot/tsconfig.json new file mode 100644 index 0000000..fd164e6 --- /dev/null +++ b/packages/bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + }, + "include": [ + "src" + ], +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..683bbe5 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,57 @@ +{ + "name": "@satorijs/plugin-im", + "description": "Background for Satori IM", + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "files": [ + "lib", + "dist" + ], + "author": "Shigma ", + "license": "MIT", + "scripts": { + "lint": "eslint src --ext .ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/koishijs/koishi-plugin-im.git", + "directory": "packages/core" + }, + "bugs": { + "url": "https://github.com/koishijs/koishi-plugin-im/issues" + }, + "keywords": [ + "satori", + "plugin", + "chat", + "im", + "database" + ], + "cordis": { + "service": { + "implements": [ + "im.auth", + "im.data", + "im.event", + "im" + ] + } + }, + "peerDependencies": { + "minato": "^3.5.0" + }, + "devDependencies": { + "@cordisjs/client": "^0.1.12", + "@cordisjs/plugin-server": "^0.2.3", + "@satorijs/core": "^4.2.6", + "@types/mime-types": "^2", + "minato": "^3.5.0" + }, + "dependencies": { + "@satorijs/core": "^4.2.6", + "@satorijs/plugin-im-utils": "^0.0.0", + "cosmokit": "^1.6.2", + "mime-types": "^2.1.35" + } +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 0000000..4a7083a --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,134 @@ +import { createHash } from 'crypto' +import { Context, Dict, Service, Universal } from '@satorijs/core' + +import { Login, User } from './types' +import { genId, validate } from '@satorijs/plugin-im-utils' + +export default class ImAuthService extends Service { + static inject = ['model', 'database'] + public logins: Dict = {} + + constructor(public ctx: Context) { + super(ctx, 'im.auth', true) + ctx.on('ready', this._load) + ctx.on('exit', this._save) + ctx.on('dispose', this._save) + } + + authenticate = async (name: string, password: string, clientId: string): Promise => { + const result = await this.ctx.database + .select( + 'satori-im.user.auth', + { + user: { name: name }, + password: this._toHash(password), + }, + { + user: true, + } + ) + .execute() + if (result.length === 0) { + throw Error() + } + const user = result[0].user + const token = this._generateToken() + const login = { + user, + token, + clientId: clientId, + status: Universal.Status.ONLINE, + updateAt: new Date().getTime(), + expiredAt: new Date().getTime() + ImLoginService.options.tokenExpiration, + } as Login + this.logins[token] = login + this.ctx.logger('im').info(`new account signed in. name: ${login.user!.name}`) + return login + } + + authenticateWithToken = async (login: Login): Promise => { + const result = await this._verifyToken(login.token) + result.clientId = login.clientId + this.ctx.logger('im').info(`new account signed in with token. name: ${result.user!.name}`) + return result + } + + register = async (name: string, password: string): Promise => { + if ( + !this._validate('name', name) || + !this._validate('password', password) || + !(await this.isNameUnique(name)) + ) { + throw Error() + } + const id = genId() + const { password: _, ...result } = await this.ctx.database.create('satori-im.user.auth', { + user: { id, name: name }, + password: this._toHash(password), + }) + this.ctx.logger('im').info(`new account signed up. name: ${name}, id: ${id}`) + return name + } + + logout = async (login: Login): Promise => { + await this.ctx.database.remove('satori-im.login', { token: login.token }) + delete this.logins[login.token] + } + + isNameUnique = async (name: string): Promise => { + const result = await this.ctx.database.get('satori-im.user', { name }) + if (result.length) { + return false + } + return true + } + + async _verifyToken(token: string): Promise { + const result = this.logins[token] + if (!result || result.expiredAt! < new Date().getTime()) { + if (result) { + this.ctx.database.remove('satori-im.login', { token: token }).then((data) => { + if (!data.removed) { + } + }) + throw Error('token expired') + } + throw Error('no token matched') + } + return result + } + + private _validate = validate + + private _load = async (): Promise => { + const result = await this.ctx.database.select('satori-im.login').execute() + result.map((record) => (this.logins[record.token] = record)) + this.ctx.logger('im').info(`loaded authorized clients, ${result.length} record(s) in total. `) + } + + private _save = async (): Promise => { + const result = await this.ctx.database.upsert('satori-im.login', Object.values(this.logins)) + if (!result.inserted) { + throw new Error() + } + this.ctx.logger('im').info(`saved authorized clients.`) + } + + private _generateToken(length = 16): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + return new Array(length) + .fill(0) + .map(() => characters[Math.floor(Math.random() * characters.length)]) + .join('') + } + + private _toHash(password: string): string { + return createHash('sha256').update(password).digest('hex') + } +} + +export namespace ImLoginService { + export const options = { + tokenExpiration: 7 * 24 * 60 * 60 * 1000, + } +} diff --git a/packages/core/src/data.ts b/packages/core/src/data.ts new file mode 100644 index 0000000..cda220f --- /dev/null +++ b/packages/core/src/data.ts @@ -0,0 +1,107 @@ +import mime from 'mime-types' +import fs from 'fs/promises' +import path from 'path' +import { Context, Service, Schema } from '@satorijs/core' +import {} from '@cordisjs/plugin-server' +import * as Model from './models' +import { Login } from './types' +import { genId } from '@satorijs/plugin-im-utils' + +// TODO: Implement role permissions. +class ImDataService extends Service { + static inject = ['server', 'model', 'database'] // FIXME: self injection + + bot: Model.BotData + user: Model.UserData + friend: Model.FriendData + guild: Model.GuildData + channel: Model.ChannelData + message: Model.MessageData + notification: Model.NotificationData + + constructor(public ctx: Context, public config: ImDataService.Config) { + super(ctx, 'im.data', true) + + // TODO: need better injections. + this.bot = new Model.BotData(this.ctx) + this.user = new Model.UserData(this.ctx) + this.friend = new Model.FriendData(this.ctx) + this.guild = new Model.GuildData(this.ctx) + this.channel = new Model.ChannelData(this.ctx) + this.message = new Model.MessageData(this.ctx) + this.notification = new Model.NotificationData(this.ctx) + + ctx.server.get('/im/v1/proxy/:url(.+)', async (koa) => { + const filePath = path.resolve(path.join(this.config.assetPath, koa.params.url)) + + try { + const fileContent = await fs.readFile(filePath) + koa.set( + 'Content-Type', + mime.contentType(path.extname(filePath)) || 'application/octet-stream' + ) + koa.body = fileContent + koa.status = 200 + } catch { + koa.status = 404 + } + }) + } + + writeAvatar = async (login: Login, b64: string, gid?: string) => { + let id = login.selfId + if (gid) { + if (!(await this.guild._isAuthorized(gid, login.selfId!))) { + throw new Error() + } + id = gid + } + + const mType = b64.match(/^data:(.*?);base64,/) + if (!mType) throw Error() + + const ext = mime.extension(mType[1]) + const filePath = path.join('avatars', `${gid ? 'g' : 'u'}-${login.selfId}.${ext}`) + const filePathAbsolute = path.join(this.config.assetPath, filePath) + + const fileData = b64.split('base64,')[1] + await fs.mkdir(path.dirname(filePathAbsolute), { recursive: true }) + await fs.writeFile(filePathAbsolute, Buffer.from(fileData, 'base64')) + + const urlPath = encodeURI(filePath.replace(/\\/g, '/')) + + gid + ? await this.guild._update(gid, { avatarUrl: urlPath }) + : await this.user._update(login.selfId!, { avatarUrl: urlPath }) + + return urlPath + } + + writeFile = async (login: Login, b64: string) => { + const mType = b64.match(/^data:(.*?);base64,/) + if (!mType) throw Error() + const ext = mime.extension(mType[1]) + const filePath = path.join('messages', `${genId()}.${ext}`) + const filePathAbsolute = path.join(this.config.assetPath, filePath) + const fileData = b64.split('base64,')[1] + + await fs.mkdir(path.dirname(filePathAbsolute), { recursive: true }) + await fs.writeFile(filePathAbsolute, Buffer.from(fileData, 'base64')) + + const urlPath = encodeURI(filePath.replace(/\\/g, '/')) + + return urlPath + } +} + +namespace ImDataService { + export interface Config { + assetPath: string + } + + export const Config: Schema = Schema.object({ + assetPath: Schema.string().default('assets/im/'), + }) +} + +export default ImDataService diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts new file mode 100644 index 0000000..84fb3c6 --- /dev/null +++ b/packages/core/src/database.ts @@ -0,0 +1,380 @@ +import { Context } from '@satorijs/core' + +export default class ImDatabase { + constructor(public ctx: Context) { + ctx.model.extend( + 'satori-im.user', + { + id: 'char(255)', + name: { + type: 'char', + length: 255, + nullable: false, + }, + nick: 'char(255)', + avatar: 'char(255)', + isBot: { + type: 'boolean', + initial: false, + }, + }, + { + primary: ['id'], + unique: ['name'], + } + ) + ctx.model.extend( + 'satori-im.user.auth', + { + user: { + type: 'oneToOne', + table: 'satori-im.user', + target: 'auth', + }, + password: { + type: 'char', + length: 255, + nullable: false, + }, + }, + { + primary: ['user'], + } + ) + ctx.model.extend( + 'satori-im.user.settings', + { + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + target: { + type: 'manyToOne', + table: 'satori-im.user', + target: 'settings', + }, + level: { + type: 'unsigned', + length: 1, + initial: 2, + }, + }, + { primary: ['user', 'target'] } + ) + ctx.model.extend( + 'satori-im.user.preferences', + { + user: { + type: 'manyToOne', + table: 'satori-im.user', + target: 'perferences', + }, + }, + { + primary: ['user'], + } + ) + ctx.model.extend( + 'satori-im.bot.command', + { + bot: { + type: 'manyToOne', + table: 'satori-im.user', + target: 'commands', + }, + 'bot.id': 'char(255)', + 'parent.name': 'char(255)', + name: 'char(255)', + description: 'json', + arguments: 'json', + options: 'json', + }, + { + primary: ['bot.id', 'name', 'parent.name'], + } + ) + // FIXME: combined unique doesnt support relation. + ctx.model.extend( + 'satori-im.friend', + { + id: 'char(255)', + 'self.id': 'char(255)', + 'target.id': 'char(255)', + createdAt: 'unsigned(8)', + deleted: { + type: 'boolean', + initial: false, + }, + }, + { + primary: ['id'], + unique: [['self.id', 'target.id']], + } + ) + ctx.model.extend( + 'satori-im.friend.settings', + { + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + friend: { + type: 'manyToOne', + table: 'satori-im.friend', + target: 'settings', + }, + group: 'char(255)', + pinned: { + type: 'boolean', + initial: false, + }, + nick: 'char(255)', + }, + { + primary: ['user', 'friend'], + } + ) + ctx.model.extend( + 'satori-im.guild', + { + id: 'char(255)', + name: { + type: 'char', + length: 255, + nullable: false, + }, + avatar: 'char(255)', + createdAt: 'unsigned(8)', + deleted: { + type: 'boolean', + initial: false, + }, + }, + { + primary: ['id'], + } + ) + ctx.model.extend( + 'satori-im.guild.settings', + { + guild: { + type: 'manyToOne', + table: 'satori-im.guild', + target: 'settings', + }, + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + group: 'char(255)', + pinned: 'boolean', + }, + { + primary: ['guild', 'user'], + } + ) + ctx.model.extend( + 'satori-im.member', + { + guild: { + type: 'manyToOne', + table: 'satori-im.guild', + target: 'members', + }, + user: { + type: 'manyToOne', + table: 'satori-im.user', + target: 'members', + }, + name: 'char(255)', // name of member identity + createdAt: 'unsigned(8)', + }, + { + primary: ['guild', 'user'], + } + ) + // FIXME: combined unique doesnt support relation. + ctx.model.extend( + 'satori-im.role', + { + id: 'string', + users: { + type: 'manyToMany', + table: 'satori-im.user', + target: 'roles', + }, + guild: { + type: 'manyToOne', + table: 'satori-im.guild', + target: 'roles', + }, + gid: 'char(255)', + name: { + type: 'char', + length: 255, + nullable: false, + }, + color: 'integer', + permissions: 'bigint', + }, + { + primary: ['id'], + unique: [['gid', 'name']], + } + ) + + ctx.model.extend( + 'satori-im.channel', + { + id: 'char(255)', + name: 'char(255)', + type: 'unsigned(1)', + parentId: 'char(255)', + friend: { + type: 'oneToOne', + table: 'satori-im.friend', + target: 'channel', + }, // FIXME: doesnt work. + guild: { + type: 'manyToOne', + table: 'satori-im.guild', + target: 'channels', + }, + deleted: { + type: 'boolean', + initial: false, + }, + }, + { + primary: ['id'], + } + ) + ctx.model.extend( + 'satori-im.channel.settings', + { + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + channel: { + type: 'manyToOne', + table: 'satori-im.channel', + target: 'settings', + }, + pinned: { + type: 'boolean', + initial: false, + }, + level: { + type: 'unsigned', + length: 1, + initial: 0, + }, + lastRead: { + type: 'unsigned', + length: 8, + initial: new Date().getTime(), + }, + }, + { + primary: ['channel', 'user'], + } + ) + ctx.model.extend( + 'satori-im.message.test', + { + id: 'char(255)', + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + channel: { + type: 'manyToOne', + table: 'satori-im.channel', + target: 'messages', + }, + 'quote.id': 'string', + content: 'text', + createdAt: 'unsigned(8)', + updatedAt: 'unsigned(8)', + deleted: { + type: 'boolean', + initial: false, + }, + }, + { + primary: ['id'], + } + ) + ctx.model.extend( + 'satori-im.message.settings', + { + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + message: { + type: 'manyToOne', + table: 'satori-im.message.test', + target: 'settings', + }, + }, + { primary: ['message', 'user'] } + ) + ctx.model.extend( + 'satori-im.login', + { + token: 'char(255)', + clientId: 'char(255)', + selfId: 'char(255)', + status: 'unsigned(1)', + updateAt: 'unsigned(8)', + expiredAt: 'unsigned(8)', + }, + { + primary: ['token'], + } + ) + ctx.model.extend( + 'satori-im.notification', + { + id: 'char(255)', + selfId: 'char(255)', + user: { + type: 'manyToOne', + table: 'satori-im.user', + target: 'notifications', + }, + guild: { + type: 'manyToOne', + table: 'satori-im.guild', + }, + shouldReply: 'boolean', + content: 'char(255)', + createdAt: 'unsigned(8)', + }, + { + primary: ['id'], + autoInc: true, + } + ) + ctx.model.extend( + 'satori-im.notification.settings', + { + self: { + type: 'manyToOne', + table: 'satori-im.notification', + target: 'settings', + }, + user: { + type: 'manyToOne', + table: 'satori-im.user', + }, + read: 'boolean', + }, + { + primary: ['self', 'user'], + } + ) + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..0d50252 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,41 @@ +import { Context, Service } from '@satorijs/core' +import ImDatabase from './database' +import ImAuthService from './auth' +import ImDataService from './data' +import ImEventService from './notifier' +import { Event } from './types' + +export * as Im from './types' + +declare module 'cordis' { + interface Context { + im: Im + 'im.data': ImDataService + 'im.event': ImEventService + 'im.auth': ImAuthService + } +} + +declare module '@satorijs/core' { + interface Events { + 'exit'(signal: NodeJS.Signals): Promise + 'im-message'(event: Event): Promise + } +} + +interface Im { + data: ImDataService + event: ImEventService + auth: ImAuthService +} + +class Im extends Service { + constructor(public ctx: Context) { + super(ctx, 'im', true) + new ImDatabase(ctx) + ctx.plugin(ImDataService) + ctx.plugin(ImEventService) + ctx.plugin(ImAuthService) + } +} +export default Im diff --git a/packages/core/src/models/bot.ts b/packages/core/src/models/bot.ts new file mode 100644 index 0000000..6eaa74b --- /dev/null +++ b/packages/core/src/models/bot.ts @@ -0,0 +1,23 @@ +import { createHash } from 'crypto' +import { Context } from '@satorijs/core' +import { Bot, Login } from '../types' + +export class BotData { + constructor(public ctx: Context) {} + + _createBot = async (name: string, password: string) => { + const crypted = createHash('sha256').update(password).digest('hex') + await this.ctx.database.create('satori-im.user.auth', { + user: { id: 'koishi', name, isBot: true }, + password: crypted, + }) + } + + fetchCommands = async (login: Login, id: string): Promise> => { + return this.ctx.database.get('satori-im.bot.command', { bot: { id } }) + } + + _updateCommands = async (commands: Bot.Command[]) => { + await this.ctx.database.upsert('satori-im.bot.command', commands) + } +} diff --git a/packages/core/src/models/channel.ts b/packages/core/src/models/channel.ts new file mode 100644 index 0000000..6ce8641 --- /dev/null +++ b/packages/core/src/models/channel.ts @@ -0,0 +1,158 @@ +import { $ } from 'minato' +import { Context } from '@satorijs/core' +import { Channel, Login } from '../types' +import { genId } from '@satorijs/plugin-im-utils' + +export class ChannelData { + constructor(public ctx: Context) {} + + fetch = async (login: Login, cid: string): Promise => { + const result = await this.ctx.database + .select( + 'satori-im.channel', + { + id: cid, + deleted: false, + settings: { + $or: [ + { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + { + $none: {}, + }, + ], + }, + }, + { + friend: true, + guild: true, + settings: true, + } + ) + .execute() + return result[0] + } + + fetchSessionList = async (login: Login): Promise> => { + const result = await this.ctx.database + .select( + 'satori-im.channel', + { + deleted: false, + settings: { + $or: [ + { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + { + $none: {}, + }, + ], + }, + messages: { + $some: (row) => $.gt(row.createdAt, MessageData.recentEndpoint()), + }, + }, + { + settings: true, + guild: true, + friend: true, + messages: true, + } + ) + .project({ + id: (row) => row.id, + type: (row) => row.type, + name: (row) => row.name, + friend: (row) => row.friend, + guild: (row) => row.guild, + settings: (row) => row.settings, + }) + .execute() + return result + } + + create = async (login: Login, gid: string, name: string): Promise => { + const result = await this.ctx.database.create('satori-im.channel', { + id: genId(), + name: name, + type: Channel.Type.TEXT, + parentId: gid, + }) + this.ctx.im.event.pushEvent({ + selfId: login.selfId!, + type: 'channel-added', + channel: result, + }) + return result + } + + update = async (login: Login, cid: string, name?: string) => { + const result = await this.ctx.database.set( + 'satori-im.channel', + { + id: cid, + deleted: false, + }, + { + name: name, + } + ) + if (!result.matched) { + throw new Error() + } + if (!result.modified) { + throw new Error() + } + this.ctx.im.event.pushEvent({ + selfId: login.selfId!, + type: 'channel-updated', + channel: { id: cid, name } as Channel, + }) + } + + updateSettings = async ( + login: Login, + cid: string, + data: { + level?: number + nick?: string + pinned?: boolean + } + ) => { + await this.ctx.database.upsert('satori-im.channel.settings', (row) => [ + { + lastRead: new Date().getTime(), + channel: { id: cid }, + user: { id: login.selfId }, + ...data, + }, + ]) + } + + softDel = async (login: Login, cid: string) => { + this.ctx.database.transact(async (db) => { + await db.set('satori-im.channel', cid, { + deleted: false, + }) + await db.remove('satori-im.channel.settings', { + channel: { id: cid }, + }) + }) + + this.ctx.im.event.pushEvent({ + selfId: login.selfId!, + type: 'channel-deleted', + channel: { id: cid } as Channel, + }) + } +} + +namespace MessageData { + export function recentEndpoint(): number { + const date = new Date() + date.setMonth(date.getMonth() - 3) + return date.getTime() + } +} diff --git a/packages/core/src/models/friend.ts b/packages/core/src/models/friend.ts new file mode 100644 index 0000000..956652f --- /dev/null +++ b/packages/core/src/models/friend.ts @@ -0,0 +1,221 @@ +import { $ } from 'minato' +import { Context } from '@satorijs/core' +import { Channel, Friend, Login, ChunkOptions } from '../types' +import { genId } from '@satorijs/plugin-im-utils' + +export class FriendData { + constructor(public ctx: Context) {} + + fetch = async (login: Login, fid: string): Promise => { + const selfId = login.selfId! + const result = await this.ctx.database + .join( + { + f: 'satori-im.friend', + u: 'satori-im.user', + us: 'satori-im.user.settings', + fs: 'satori-im.friend.settings', + c: 'satori-im.channel', + }, + (row) => + $.and( + $.or( + $.and($.eq(row.f.target.id, login.selfId!), $.eq(row.f.self.id, row.u.id)), + $.and($.eq(row.f.self.id, login.selfId!), $.eq(row.f.target.id, row.u.id)) + ), + $.eq(row.f.id, row.fs.friend.id), + $.eq(row.u.id, row.us.user.id), + $.eq(row.c.parentId, row.f.id), + $.eq(row.c.type, Channel.Type.DIRECT) + ), + { + f: false, + u: false, + c: false, + us: true, + fs: true, + } + ) + .where((row) => $.and($.ne(row.u.id, selfId), $.eq(row.f.id, fid))) + .project({ + id: (row) => row.f.id, + createdAt: (row) => row.f.createdAt, + user: (row) => row.u, + channel: (row) => row.c, + nick: (row) => row.fs.nick, + level: (row) => row.us.level, + pinned: (row) => row.fs.pinned, + group: (row) => row.fs.group, + }) + .execute() + + return result[0] + } + + _fetch = async (fid: string): Promise => { + const result = await this.ctx.database.get('satori-im.friend', { + id: fid, + }) + return result[0] + } + + list = async (login: Login): Promise> => { + const selfId = login.selfId! + // const result = await this.ctx.database + // .select( + // 'satori-im.friend', + // (row) => $.or($.eq(row.target.id, selfId), $.eq(row.self.id, selfId)), + // { + // settings: true, + // channel: true, + // } + // ) + // .execute() + // result.map((value) => value.settings!.filter((value) => value.user.id === login.selfId)) + // return result + const result = await this.ctx.database + .join( + { + f: 'satori-im.friend', + u: 'satori-im.user', + c: 'satori-im.channel', + us: 'satori-im.user.settings', + fs: 'satori-im.friend.settings', + }, + (row) => $.and($.eq(row.f.id, row.fs.friend.id), $.eq(row.u.id, row.us.user.id)), + { + f: false, + u: false, + c: false, + us: true, + fs: true, + } + ) + .where((row) => + $.and( + $.or( + $.and($.eq(row.f.target.id, selfId), $.eq(row.f.self.id, row.u.id)), + $.and($.eq(row.f.self.id, selfId), $.eq(row.f.target.id, row.u.id)) + ), + $.ne(row.u.id, selfId), + $.eq(row.c.parentId, row.f.id), + $.eq(row.c.type, Channel.Type.DIRECT) + ) + ) + .project({ + id: (row) => row.f.id, + createdAt: (row) => row.f.createdAt, + user: (row) => row.u, + channel: (row) => row.c, + nick: (row) => row.fs.nick, + level: (row) => row.us.level, + pinned: (row) => row.fs.pinned, + group: (row) => row.fs.group, + }) + .execute() + + return result + } + + update = async (login: Login, fid: string, common?: {}, critical?: {}) => { + // const selfId = login.selfId! + // const result = await this.ctx.database.set( + // 'satori-im.friend', + // (row) => + // $.and($.or($.eq(row.target.id, selfId), $.eq(row.self.id, selfId)), $.eq(row.id, fid)), + // { + // pinned, + // nick: nick, + // group: group, + // } + // ) + // if (!result.matched) { + // throw new Error() + // } + // if (!result.modified) { + // throw new Error() + // } + // this.ctx.im.event.pushEvent({ + // type: 'friend-updated', + // selfId, + // friend: { id: fid, pinned, nick, group } as Friend, + // }) + } + + softDel = async (login: Login, fid: string) => { + const selfId = login.selfId! + this.ctx.database.transact(async (db) => { + await db.set( + 'satori-im.friend', + (row) => + $.and($.or($.eq(row.target.id, selfId), $.eq(row.self.id, selfId)), $.eq(row.id, fid)), + { + deleted: true, + } + ) + await db.set( + 'satori-im.channel', + { + parentId: fid, + type: Channel.Type.DIRECT, + }, + { + deleted: true, + } + ) + await db.remove('satori-im.friend.settings', { + friend: { id: fid }, + }) + await db.remove('satori-im.channel.settings', { + channel: { parentId: fid, type: Channel.Type.DIRECT }, + }) + }) + this.ctx.im.event.pushEvent({ + type: 'friend-deleted', + selfId, + friend: { id: fid } as Friend, + }) + } + + search = async ( + login: Login, + keyword: string, + options: ChunkOptions & { except: Array } + ): Promise> => { + const keywordRegex = new RegExp(`${keyword}`, 'i') + let query = this.ctx.database + .join( + { f: 'satori-im.friend', user: 'satori-im.user', settings: 'satori-im.friend.settings' }, + (row) => + $.and( + $.or( + $.and($.eq(row.f.target.id, login.selfId!), $.eq(row.f.self.id, row.user.id)), + $.and($.eq(row.f.self.id, login.selfId!), $.eq(row.f.target.id, row.user.id)) + ), + $.or($.eq(row.f.id, row.settings.friend.id), $.eq(row.settings.friend.id, '')), + $.or( + $.regex(row.user.name, keywordRegex), + $.regex(row.user.nick, keywordRegex), + $.eq(row.user.id, keyword) + ), + ...options.except.map((value) => $.ne(row.user.id, value)) + ), + { + f: false, + user: false, + settings: true, + } + ) + .where((row) => $.ne(row.user.id, login.selfId!)) + if (options.cursor === '#begin') { + query = query.orderBy('user.id', 'asc') + } else if (options.direction === 'before') { + query = query.where((row) => $.lt(row.user.id, options.cursor)).orderBy('user.id', 'desc') + } else if (options.direction === 'after') { + query = query.where((row) => $.gt(row.user.id, options.cursor)).orderBy('user.id', 'asc') + } else { + throw Error() + } + return query.limit(options.limit).execute() + } +} diff --git a/packages/core/src/models/guild.ts b/packages/core/src/models/guild.ts new file mode 100644 index 0000000..44dc5f2 --- /dev/null +++ b/packages/core/src/models/guild.ts @@ -0,0 +1,483 @@ +import { $ } from 'minato' +import { Context } from '@satorijs/core' +import { Channel, Guild, Login, Member, Role, ChunkOptions } from '../types' +import { genId } from '@satorijs/plugin-im-utils' + +export class GuildData { + constructor(public ctx: Context) {} + + fetch = async (login: Login, gid: string): Promise => { + const result = await this.ctx.database + .select( + 'satori-im.guild', + { + id: gid, + members: { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + channels: { + $some: { + settings: { + $or: [ + { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + { + $none: {}, + }, + ], + }, + }, + }, + settings: { + $or: [ + { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + { + $none: {}, + }, + ], + }, + }, + { + channels: true, + settings: true, + members: true, + } + ) + .execute() + + return result[0] + } + + _fetch = async (gid: string): Promise => { + const result = await this.ctx.database + .select( + 'satori-im.guild', + { + id: gid, + }, + { + members: true, + settings: true, + channels: true, + } + ) + .execute() + return result[0] + } + + list = async (login: Login): Promise> => { + const result = await this.ctx.database.get('satori-im.guild', { + members: { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + channels: { + $some: { + settings: { + $or: [ + { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + { + $none: {}, + }, + ], + }, + }, + }, + settings: { + $or: [ + { + $some: (row) => $.eq(row.user.id, login.selfId!), + }, + { + $none: {}, + }, + ], + }, + }) + return result as any + } + + create = async ( + login: Login, + name: string, + options: { inviteUsers?: string[]; initialChannelName?: string } + ): Promise => { + const selfId = login.selfId! + const timestamp = new Date().getTime() + const result: Guild = await this.ctx.database.transact(async (db): Promise => { + const result = await db.create('satori-im.guild', { + id: genId(), + name, + createdAt: timestamp, + }) + const owner = await db.create('satori-im.member', { + guild: result, + user: { $connect: { id: selfId } }, + }) + const role = await db.create('satori-im.role', { + id: genId(), + name: '$a', + guild: { $connect: { id: result.id } }, + gid: result.id, + users: { $connect: { id: selfId } }, + }) + const channel = await db.create('satori-im.channel', { + id: genId(), + parentId: result.id, + guild: { $connect: { id: result.id } }, + name: options.initialChannelName ? options.initialChannelName : '主要频道', + }) + + return { + ...result, + members: [ + { + ...owner, + roles: [role as any], + }, + ], + channels: [channel], + } + }) + + if (options.inviteUsers) { + const toInvite = options.inviteUsers + for (let i = 0; i < toInvite.length; i++) { + const friend = await this.ctx.database.get('satori-im.friend', (row) => + $.or( + $.and($.eq(row.self.id, login.selfId!), $.eq(row.target.id, toInvite[i])), + $.and($.eq(row.target.id, login.selfId!), $.eq(row.self.id, toInvite[i])) + ) + ) + if (friend) { + this.ctx.im.data.notification.request(login, toInvite[i], '', result.id) + } + } + } + + this.ctx['im.event'].pushEvent({ + selfId: login.selfId!, + type: 'guild-added', + guild: result, + }) + return result + } + + update = async (login: Login, gid: string, name: string) => { + const result = await this.ctx.database.set('satori-im.guild', gid, { + name, + }) + if (!result.matched) { + throw new Error() + } + if (!result.modified) { + throw new Error() + } + this.ctx['im.event'].pushEvent({ + type: 'guild-updated', + selfId: login.selfId!, + guild: { id: gid, name } as Guild, + }) + } + + _update = async (gid: string, data: { name?: string; avatarUrl?: string }) => { + const result = await this.ctx.database.set( + 'satori-im.guild', + { id: gid }, + { + name: data.name, + avatar: data.avatarUrl, + } + ) + if (!result.matched) { + throw new Error() + } + const guild = await this._fetch(gid) + this.ctx['im.event'].pushEvent({ + type: 'guild-updated', + selfId: '', + guild, + }) + } + + softDel = async (login: Login, gid: string) => { + this.ctx.database.transact(async (db) => { + await db.set('satori-im.guild', gid, { deleted: true }) + await db.remove('satori-im.guild.settings', { + guild: { id: gid }, + }) + await db.set( + 'satori-im.channel', + { + parentId: gid, + type: { $not: Channel.Type.DIRECT }, + }, + { + deleted: true, + } + ) + await db.remove('satori-im.channel.settings', { + channel: { parentId: gid, type: { $not: Channel.Type.DIRECT } }, + }) + }) + this.ctx['im.event'].pushEvent({ + type: 'guild-deleted', + selfId: login.selfId!, + guild: { id: gid } as Guild, + }) + } + + // HACK: shouldnt get guilds which the user has joined. + search = async ( + login: Login, + keyword: string, + options: ChunkOptions & { except: Array } + ): Promise> => { + const keywordRegex = new RegExp(`${keyword}`, 'i') + let query = this.ctx.database + .select('satori-im.guild') + .where((row) => + $.and( + $.or($.eq(row.id, keyword), $.regex(row.name, keywordRegex)), + ...options.except.map((value) => $.ne(row.id, value)) + ) + ) + if (options.cursor === '#begin') { + query = query.orderBy('id', 'asc') + } else if (options.direction === 'before') { + query = query.where((row) => $.lt(row.id, options.cursor)).orderBy('id', 'desc') + } else if (options.direction === 'after') { + query = query.where((row) => $.gt(row.id, options.cursor)).orderBy('id', 'asc') + } else { + throw Error() + } + if (options.offset) { + query = query.offset(options.offset) + } + return query.limit(options.limit).execute() + } + + async _isGuildMember(gid: string, uid: string): Promise { + const result = await this.ctx.database.get('satori-im.member', { + guild: { id: gid }, + user: { id: uid }, + }) + return result.length ? true : false + } + + async _isAuthorized(gid: string, uid: string): Promise { + // TODO: + return true + } + + public Member = { + fetch: async (login: Login, gid: string, uid: string): Promise => { + if (!(await this._isGuildMember(gid, login.selfId!))) + return { e: 'unauthorized' } as any as Member + + const result = await this.ctx.database + .select( + 'satori-im.member', + { + user: { + id: uid, + roles: { + $or: [ + { + $some: (row) => $.eq(row.guild.id, gid), + }, + { + $none: {}, + }, + ], + }, + }, + guild: { id: gid }, + }, + { + user: { + roles: true, + }, + } + ) + .project({ + name: (row) => row.name, + roles: (row) => row.user.roles, + guild: (row) => row.guild, + user: (row) => row.user, + }) + .execute() + + return result[0] as any + }, + + list: async (login: Login, gid: string): Promise> => { + if (!(await this._isGuildMember(gid, login.selfId!))) + return { e: 'unauthorized' } as any as Array + + const result = await this.ctx.database + .select( + 'satori-im.member', + { + user: { + roles: { + $or: [ + { + $some: { + guild: { id: gid }, + }, + }, + { + $none: { + guild: { id: gid }, + }, + }, + ], + }, + }, + guild: { id: gid }, + }, + { + user: { + roles: true, + }, + } + ) + .project({ + name: (row) => row.name, + roles: (row) => row.user.roles, + guild: (row) => row.guild, + user: (row) => row.user, + }) + .execute() + return result as any + }, + + // TODO: Update member.role. + update: async (login: Login, gid: string, uid?: string, name?: string) => { + const result = await this.ctx.database.set( + 'satori-im.member', + { + guild: { id: gid }, + user: { id: login.selfId }, + }, + { + name: name, + } + ) + if (!result.matched) { + throw new Error() + } + if (!result.modified) { + throw new Error() + } + this.ctx['im.event'].pushEvent({ + type: 'guild-member-updated', + selfId: login.selfId!, + member: { + guild: { id: gid }, + user: { id: login.selfId! }, + name: name, + }, + }) + }, + + kick: async (login: Login, gid: string, uid: string) => { + const result = await this.ctx.database.remove('satori-im.member', { + guild: { id: gid }, + user: { id: uid }, + }) + if (!result.matched) { + throw new Error() + } + if (!result.removed) { + throw new Error() + } + this.ctx['im.event'].pushEvent({ + type: 'guild-member-deleted', + selfId: login.selfId!, + member: { + guild: { id: gid }, + user: { id: login.selfId! }, + }, + }) + }, + } + + public Role = { + fetch: async (login: Login, gid: string, rid: string): Promise => { + const result = await this.ctx.database.get('satori-im.role', rid) + return result[0] + }, + + list: async (login: Login, gid: string): Promise> => { + const result = await this.ctx.database.get('satori-im.role', { + guild: { id: gid }, + }) + return result + }, + + create: async (login: Login, gid: string, name: string, color: number): Promise => { + const result = await this.ctx.database.create('satori-im.role', { + id: genId(), + guild: { id: gid }, + gid, + name, + color, + }) + return result + }, + + // TODO: Add authority. + update: async (login: Login, rid: string, name?: string, color?: number) => { + const result = await this.ctx.database.set('satori-im.role', rid, { + name, + color, + }) + if (!result.matched) { + throw new Error() + } + if (!result.modified) { + throw new Error() + } + }, + + set: async (login: Login, rid: string, uid: string) => { + await this.ctx.database.set( + 'satori-im.role', + { + id: rid, + }, + { + users: { $upsert: { id: uid } }, + } + ) + }, + + unset: async (login: Login, rid: string, uid: string) => { + await this.ctx.database.set( + 'satori-im.role', + { + id: rid, + }, + { + users: { $remove: { id: uid } }, + } + ) + }, + + hardDel: async (login: Login, rid: string) => { + const result = await this.ctx.database.remove('satori-im.role', rid) + if (!result.matched) { + throw new Error() + } + if (!result.removed) { + throw new Error() + } + }, + } +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts new file mode 100644 index 0000000..2417af7 --- /dev/null +++ b/packages/core/src/models/index.ts @@ -0,0 +1,7 @@ +export * from './bot' +export * from './channel' +export * from './friend' +export * from './user' +export * from './guild' +export * from './message' +export * from './notification' diff --git a/packages/core/src/models/message.ts b/packages/core/src/models/message.ts new file mode 100644 index 0000000..a187e31 --- /dev/null +++ b/packages/core/src/models/message.ts @@ -0,0 +1,138 @@ +import { $ } from 'minato' +import { Context } from '@satorijs/core' +import { Channel, Friend, Login, Message, Guild, Member } from '../types' +import { genId } from '@satorijs/plugin-im-utils' + +export class MessageData { + constructor(public ctx: Context) {} + + fetch = async ( + login: Login, + cid: string, + endpoint: number, + dir: Message.Direction = 'before', + limit: number = 50 + ): Promise => { + let query = this.ctx.database + .select('satori-im.message.test', (row) => + $.and( + dir === 'before' ? $.lt(row.createdAt, endpoint) : $.gt(row.createdAt, endpoint), + $.not(row.deleted), + $.eq(row.channel.id, cid) + ) + ) + .orderBy('createdAt', dir === 'before' ? 'desc' : 'asc') + + if (limit > 0) { + query = query.limit(limit) + } + return query.execute() + } + + getUnreadCount = async (login: Login, cid: string): Promise => { + // TODO: + const channel = ( + await this.ctx.database.get('satori-im.channel', { + id: cid, + }) + )[0] + + const defaultLastRead = + channel?.type !== Channel.Type.DIRECT + ? ( + await this.ctx.database.get('satori-im.member', { + user: { id: login.selfId }, + guild: { id: channel.guild!.id }, + }) + )[0].createdAt! + : 0 + + let query = await this.ctx.database + .join( + { m: 'satori-im.message.test', cs: 'satori-im.channel.settings' }, + (row) => + $.and($.eq(row.m.channel.id, row.cs.channel.id), $.eq(row.cs.user.id, login.selfId!)), + { + m: false, + cs: true, + } + ) + .where((row) => + $.and( + $.eq(row.m.channel.id, cid), + $.or( + $.gt(row.m.createdAt, row.cs.lastRead), + $.and($.eq($.ifNull(row.cs.lastRead, -1), -1), $.gt(row.m.createdAt, defaultLastRead)) + ) + ) + ) + .groupBy('cs.user.id', { + unread: (row) => $.count(row.m), + }) + .execute() + + return query[0]?.unread || 0 + } + + create = async (login: Login, data: Message) => { + let guild: Guild | undefined = undefined + let member: Member | undefined = undefined + let friend: Friend | undefined = undefined + const channel = await this.ctx.im.data.channel.fetch(login, data.channel.id) + + if (data.channel!.type !== Channel.Type.DIRECT) { + guild = await this.ctx.im.data.guild.fetch(login, channel.guild!.id) + member = await this.ctx.im.data.guild.Member.fetch(login, guild.id, login.selfId!) + } else { + friend = await this.ctx.im.data.friend._fetch(channel.friend!.id) + } + + const result = await this.ctx.database.create('satori-im.message.test', { + id: genId(), + createdAt: new Date().getTime(), + content: data.content, + channel: { $literal: { id: data.channel!.id } }, + user: { $literal: { id: data.user!.id } }, + quote: { + id: data.quote?.id, + }, + }) + + const user = await this.ctx.im.data.user.fetch(login, login.selfId!) + this.ctx.im.event.pushEvent({ + selfId: login.selfId!, + type: 'message', + message: { ...result, user, member, sid: data.sid }, + channel: result.channel, + guild, + friend, + }) + } + + update = async (login: Login, cid: string, mid: string, content: string) => { + await this.ctx.database.set( + 'satori-im.message.test', + { + id: mid, + deleted: false, + }, + { content } + ) + this.ctx.im.event.pushEvent({ + selfId: login.selfId!, + type: 'message-updated', + message: { id: mid, content } as Message, + channel: { id: cid } as Channel, + }) + } + + softDel = async (login: Login, cid: string, mid: string) => { + await this.ctx.database.set('satori-im.message.test', { id: mid }, { deleted: true }) + this.ctx.im.event.pushEvent({ + selfId: login.selfId!, + type: 'message-deleted', + message: { id: mid } as Message, + channel: { id: cid } as Channel, + }) + } +} diff --git a/packages/core/src/models/notification.ts b/packages/core/src/models/notification.ts new file mode 100644 index 0000000..a5c2a5a --- /dev/null +++ b/packages/core/src/models/notification.ts @@ -0,0 +1,147 @@ +import { $ } from 'minato' +import { Context } from '@satorijs/core' +import { Login } from '../types' +import { genId } from '@satorijs/plugin-im-utils' +import { Notification, Channel, Guild, Friend } from '../types' + +declare module '../types' { + namespace Notification { + interface Type { + 'new-friendship': Friend + 'new-guild': Guild + 'member-kick': void + } + } +} + +export class NotificationData { + constructor(public ctx: Context) {} + + list = async (login: Login) => { + const result = await this.ctx.database + .select( + 'satori-im.notification', + { + user: { id: login.selfId }, + }, + { + settings: true, + guild: true, + } + ) + .execute() + return result + } + + request = async (login: Login, target: string, content: string, gid?: string) => { + const data: Notification = { + id: genId(), + createdAt: new Date().getTime(), + shouldReply: true, + selfId: login.selfId!, + content, + } + + let guild: Guild | undefined = undefined + + // HACK: cannot judge the case that row.guild.id is null or not. + const result = gid + ? await this.ctx.database + .select('satori-im.notification', (row) => + $.and($.eq(row.selfId, login.selfId!), $.eq(row.user.id, target)) + ) + .execute() + : await this.ctx.database + .select('satori-im.notification', (row) => + $.and( + $.eq(row.selfId, login.selfId!), + $.eq(row.user.id, target), + $.eq(row.guild.id, gid!) + ) + ) + .execute() + + this.ctx.logger.info(result) + + if (!result.length) { + const res = await this.ctx.database.create('satori-im.notification', { + ...data, + user: { $literal: { id: target } }, + guild: { $literal: { id: gid } }, + }) + + if (gid) { + guild = await this.ctx.im.data.guild.fetch(login, gid) + } + + const invType = data.guild ? 'guild-member' : 'friend' + this.ctx['im.event'].pushEvent({ + selfId: data.selfId!, + type: data.shouldReply ? `${invType}-request` : 'notification-added', + notification: res, + user: { id: target }, + _data: data, + }) // HACK: _data is not ideal. + } + } + + reply = async (login: Login, nid: string, reply: boolean) => { + const result = await this.ctx.database.get('satori-im.notification', { + id: nid, + shouldReply: true, + }) + if (!result.length) { + return new Error() + } + const notification = result[0] + const isGuild = !!notification.guild!.id + + let creation: any + // HACK: directly created a relation here + if (reply === true) { + this.ctx.database.transact(async (db) => { + if (reply) { + if (isGuild) { + await db.create('satori-im.member', { + user: { $connect: { id: login.selfId } }, + guild: { $connect: { id: notification.guild!.id } }, + createdAt: new Date().getTime(), + }) + } else { + creation = await db.create('satori-im.friend', { + id: genId(), + self: { id: login.selfId }, + target: { id: notification.selfId }, + createdAt: new Date().getTime(), + }) + const channel = await db.create('satori-im.channel', { + id: genId(), + type: Channel.Type.DIRECT, + friend: { id: creation.id }, + parentId: creation.id, + }) + await db.create('satori-im.channel.settings', { + user: { $literal: { id: login.selfId } }, + channel: { $literal: { id: channel.id } }, + }) + await db.create('satori-im.friend.settings', { + user: { $literal: { id: login.selfId } }, + friend: { $literal: { id: creation.id } }, + }) + await db.create('satori-im.friend.settings', { + user: { $literal: { id: notification.selfId } }, + friend: { $literal: { id: creation.id } }, + }) + } + } + await db.remove('satori-im.notification', { id: nid }) + this.ctx['im.event'].pushEvent({ + selfId: login.selfId!, + type: `${isGuild ? 'guild' : 'friend'}-updated`, + guild: isGuild ? (creation as Guild) : undefined, + friend: !isGuild ? (creation as Friend) : undefined, + }) + }) + } + } +} diff --git a/packages/core/src/models/user.ts b/packages/core/src/models/user.ts new file mode 100644 index 0000000..690bf0b --- /dev/null +++ b/packages/core/src/models/user.ts @@ -0,0 +1,76 @@ +import { $ } from 'minato' +import { Context } from '@satorijs/core' +import { ChunkOptions, Login, User } from '../types' + +export class UserData { + constructor(public ctx: Context) {} + + fetch = async (login: Login, uid: string): Promise => { + const result = await this.ctx.database.select('satori-im.user', { id: uid }).execute() + return result[0] + } + + fetchSettings = async (login: Login): Promise => { + const result = await this.ctx.database + .select('satori-im.user.preferences', { user: { id: login.selfId } }) + .execute() + return result[0] + } + + update = async (login: Login, nick: string) => { + const result = await this.ctx.database.set('satori-im.user', { id: login.selfId }, (row) => ({ + nick: nick, + })) + if (!result.modified) { + throw new Error() + } + } + + _update = async (uid: string, data: { nick?: string; avatarUrl?: string }) => { + const result = await this.ctx.database.set('satori-im.user', { id: uid }, (row) => ({ + nick: data.nick, + avatar: data.avatarUrl, + })) + if (!result.modified) { + throw new Error() + } + + // FIXME: update event + // this.ctx['im.event'].pushEvent({ + // type: 'user-updated', + // selfId: uid, + // guild: { id: gid, name } as Guild, + // }) + } + + search = async ( + login: Login, + keyword: string, + options: ChunkOptions & { except: Array } + ): Promise> => { + const keywordRegex = new RegExp(`${keyword}`, 'i') + let query = this.ctx.database + .select('satori-im.user') + .where((row) => + $.and( + $.or( + $.regex(row.name, keywordRegex), + $.regex(row.nick, keywordRegex), + $.eq(row.id, keyword) + ), + ...options.except.map((value) => $.ne(row.id, value)) + ) + ) + + if (options.cursor === '#begin') { + query = query.orderBy('id', 'asc') + } else if (options.direction === 'before') { + query = query.where((row) => $.lt(row.id, options.cursor)).orderBy('id', 'desc') + } else if (options.direction === 'after') { + query = query.where((row) => $.gt(row.id, options.cursor)).orderBy('id', 'asc') + } else { + throw Error() + } + return query.limit(options.limit).execute() + } +} diff --git a/packages/core/src/notifier.ts b/packages/core/src/notifier.ts new file mode 100644 index 0000000..1d8f2a4 --- /dev/null +++ b/packages/core/src/notifier.ts @@ -0,0 +1,64 @@ +import { Context, Dict, Service } from '@satorijs/core' +import { Event, Login } from './types' + +export default class ImEventService extends Service { + static inject = ['database', 'im', 'im.auth'] + private currentEventId: number // ? + eventChans: Dict = {} + + constructor(public ctx: Context) { + super(ctx, 'im.event', true) + this.currentEventId = 1 + } + + pushEvent(event: Omit) { + const data: Event = { + id: this.currentEventId, + platform: 'satori-im', + timestamp: new Date().getTime(), + ...event, + } + this.currentEventId += 1 + this._pushEvent(data) + } + + private _pushEvent(data: Event) { + // HACK: directly emit events to the only bot. + this.ctx.emit('im-message', data) + + const receivers: Dict = {} + if (data.guild) { + for (let i = 0; i < data.guild.members!.length; i++) { + receivers[data.guild.members![i].user.id] = true + } + } else if (data.friend) { + receivers[data.friend.self.id] = true + receivers[data.friend.target.id] = true + } else if (data.user) { + receivers[data.user.id] = true + } + this.ctx.logger('im.debug').info(data) + this.ctx.logger('im.debug').info(receivers) + + const logins = this.ctx.im.auth.logins + const flag: Dict = {} + + // TODO: should delete the expired? + // TODO: fit event size. + for (const key in logins) { + const userId = logins[key].user!.id + const clientId = logins[key].clientId! + if (receivers[userId]) { + if (!flag[userId]) { + this.eventChans[userId] = data + flag[userId] = true + } + this.ctx.inject(['im.entry'], (ctx) => { + ctx['im.entry'].entry.unicast(clientId, { + eventChan: this.eventChans[userId], + }) + }) + } + } + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..4a04230 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,257 @@ +import { Universal } from '@satorijs/core' +import { Row } from 'minato' + +declare module 'minato' { + interface Tables { + 'satori-im.bot.command': Bot.Command + 'satori-im.channel.settings': Channel.Settings + 'satori-im.channel': Channel + 'satori-im.friend.settings': Friend.Settings + 'satori-im.friend': Friend + 'satori-im.guild.settings': Guild.Settings + 'satori-im.guild': Guild + 'satori-im.login': Login + 'satori-im.member': Member + 'satori-im.message.settings': Message.Settings + 'satori-im.message.test': Message + 'satori-im.role': Role + 'satori-im.user.preferences': User.Preferences + 'satori-im.user.auth': User.Auth + 'satori-im.user.settings': User.Settings + 'satori-im.user': User + 'satori-im.notification': Notification + 'satori-im.notification.settings': Notification.Settings + } +} + +export function extractSettings( + row: Row +): Row.Cell | undefined { + if (row.settings && row.settings.length > 0) { + return row.settings[0] + } + return undefined +} + +export interface Login extends Universal.Login { + clientId?: string + selfId?: string + token: string + updateAt?: number + expiredAt?: number +} + +export interface Notification { + id: string + selfId: string + shouldReply: boolean + // type: Notification.Type + createdAt?: number + user?: User + guild?: Guild + content?: string + settings?: Array +} + +export namespace Notification { + export interface Type { + announcement: void + 'bump-version': void + } + + export interface Settings { + self: Notification + user: User + read: boolean + } +} + +export type EventName = Universal.EventName | 'notification-added' +export interface Event extends Universal.Event { + type: EventName + channel?: Channel + friend?: Friend + guild?: Guild + member?: Member + message?: Message + notification?: Notification + role?: Role + user?: User +} + +export interface User extends Universal.User { + settings?: Array + roles?: Array + members?: Array + deleted?: boolean + commands?: Array +} + +export namespace User { + export interface Settings { + user: User + target: User + level: NotifyLevels + } + + export type Payload = Omit + + export interface Auth { + user: User + password: string + } + + export interface Preferences { + user: User + } +} + +export namespace Bot { + export interface Command extends Universal.Command { + bot: User + parent: Command + } +} + +export interface Friend { + id: string + self: User + target: User + createdAt?: number + deleted?: boolean + settings?: Array + channel?: Channel + messages?: Array +} + +export namespace Friend { + export interface Settings { + user: User + friend: Friend + pinned: boolean + group?: string + nick?: string + } + + export type Payload = Omit & { + user: User + nick?: string + level: NotifyLevels + pinned: boolean + group?: string + } +} + +export interface Guild extends Universal.Guild { + members?: Array + roles?: Array + settings?: Array + channels?: Array + createdAt?: number + deleted?: boolean +} + +export namespace Guild { + export interface Settings { + guild: Guild + user: User + group: string + pinned: boolean + } + export type Payload = Omit & { + group?: string + pinned: boolean + channels: Array + } +} + +export interface Member extends Universal.GuildMember { + guild: Guild + user: User + createdAt?: number +} + +// HACK: related to member instead of user. +export interface Role extends Universal.GuildRole { + gid: string + users?: Array + guild: Guild +} + +export interface Channel extends Universal.Channel { + friend?: Friend + guild?: Guild + deleted?: boolean + settings?: Array + messages?: Array +} + +export namespace Channel { + export interface Settings { + user: User + channel: Channel + level: NotifyLevels + nick?: string + pinned: boolean + lastRead: number + } + + export type Payload = Omit & { + level: NotifyLevels + nick?: string + pinned: boolean + lastRead?: number + } + + export import Type = Universal.Channel.Type +} + +export interface Message extends Universal.Message { + sid?: string // message sync id. + user?: User + member?: Member + channel: Channel + quote?: Message + deleted?: boolean +} + +export namespace Message { + export interface Settings { + message: Message + user: User + } + + export enum Flag { + FRONT = 1, + BACK = 2, + FINAL = 4, + } + + export type Direction = 'before' | 'after' + + // 序列化 + + // 创建 Message +} + +export type IdOnly = Pick + +export enum NotifyLevels { + BLOCKED = 0, + SILENT = 1, + NORMAL = 2, + IMPORTANT = 3, +} + +export interface ChunkOptions { + direction: 'before' | 'after' + cursor: string + limit: number + offset?: number // used when skipping pages. +} + +export interface ChunkData { + current: number + total: number + data: Array<{ id: string }> +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..e193a11 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + }, + "include": [ + "src", + ], +} \ No newline at end of file diff --git a/packages/im/client/app/aside/aside-guild.vue b/packages/im/client/app/aside/aside-guild.vue new file mode 100644 index 0000000..8d0a834 --- /dev/null +++ b/packages/im/client/app/aside/aside-guild.vue @@ -0,0 +1,223 @@ + + + diff --git a/packages/im/client/app/aside/aside-user.vue b/packages/im/client/app/aside/aside-user.vue new file mode 100644 index 0000000..16dbc7a --- /dev/null +++ b/packages/im/client/app/aside/aside-user.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/im/client/app/aside/aside.vue b/packages/im/client/app/aside/aside.vue new file mode 100644 index 0000000..d35c813 --- /dev/null +++ b/packages/im/client/app/aside/aside.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/im/client/app/aside/index.ts b/packages/im/client/app/aside/index.ts new file mode 100644 index 0000000..f09f8ca --- /dev/null +++ b/packages/im/client/app/aside/index.ts @@ -0,0 +1,12 @@ +import { Context } from '@cordisjs/client' +import settings from './settings' +import Aside from './aside.vue' + +export { default as AsideUser } from './aside-user.vue' +export { default as AsideGuild } from './aside-guild.vue' + +export default function (ctx: Context) { + ctx.app.component('im-aside', Aside) + + ctx.app.use(settings) +} diff --git a/packages/im/client/app/aside/settings/collapse.vue b/packages/im/client/app/aside/settings/collapse.vue new file mode 100644 index 0000000..cfea904 --- /dev/null +++ b/packages/im/client/app/aside/settings/collapse.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/im/client/app/aside/settings/form.vue b/packages/im/client/app/aside/settings/form.vue new file mode 100644 index 0000000..ef1b34a --- /dev/null +++ b/packages/im/client/app/aside/settings/form.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/im/client/app/aside/settings/index.scss b/packages/im/client/app/aside/settings/index.scss new file mode 100644 index 0000000..8577320 --- /dev/null +++ b/packages/im/client/app/aside/settings/index.scss @@ -0,0 +1,7 @@ +.im-settings { + font-size: 0.875rem; + min-height: 2.5rem; + font-weight: bold; + padding: 0.5rem 0.25rem; + box-sizing: border-box; +} diff --git a/packages/im/client/app/aside/settings/index.ts b/packages/im/client/app/aside/settings/index.ts new file mode 100644 index 0000000..232daf3 --- /dev/null +++ b/packages/im/client/app/aside/settings/index.ts @@ -0,0 +1,14 @@ +import { App } from 'vue' +import Collapse from './collapse.vue' +import Form from './form.vue' +import Input from './input.vue' +import Select from './selector.vue' +import Switch from './switch.vue' + +export default function (app: App) { + app.component('settings-collapse', Collapse) + app.component('settings-form', Form) + app.component('settings-input', Input) + app.component('settings-select', Select) + app.component('settings-switch', Switch) +} diff --git a/packages/im/client/app/aside/settings/input.vue b/packages/im/client/app/aside/settings/input.vue new file mode 100644 index 0000000..8be00bc --- /dev/null +++ b/packages/im/client/app/aside/settings/input.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/im/client/app/aside/settings/selector.vue b/packages/im/client/app/aside/settings/selector.vue new file mode 100644 index 0000000..f4aa563 --- /dev/null +++ b/packages/im/client/app/aside/settings/selector.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/im/client/app/aside/settings/switch.vue b/packages/im/client/app/aside/settings/switch.vue new file mode 100644 index 0000000..7e191a7 --- /dev/null +++ b/packages/im/client/app/aside/settings/switch.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/im/client/app/index.ts b/packages/im/client/app/index.ts new file mode 100644 index 0000000..5dd056c --- /dev/null +++ b/packages/im/client/app/index.ts @@ -0,0 +1,132 @@ +import { computed, ref, Ref } from 'vue' +import { Context, send } from '@cordisjs/client' +import type { Im } from '@satorijs/plugin-im' +import aside, { AsideGuild, AsideUser } from './aside' +import navigation from './navigation' +import messenger, { ChatScene } from './messenger' +import user, { MyInfoScene, GlobalSearchScene } from './user' +import Scene, { Native } from './scene' + +export { default as Scene, Native } from './scene' +export { default as ImApp } from './index.vue' + +declare module '@cordisjs/client' { + interface ActionContext { + 'chat-session': void + 'user-settings': void + } +} + +declare module './scene' { + interface Scenes { + 'chat-friend': { + messages?: Ref> + friend: Im.Friend.Payload + channel: Im.Channel + } + 'chat-guild': { messages?: Ref>; guild: Im.Guild; channel: Im.Channel } + } +} + +export const inject = ['im.client'] + +export default function (ctx: Context) { + ctx.plugin(user) + ctx.plugin(aside) + ctx.plugin(navigation) + ctx.plugin(messenger) + + ctx.inject(['im.client'], (injected) => { + const chat = injected['im.client'] + + Scene.register('chat-friend', ChatScene, AsideUser, { + onInit: async (scene) => { + const messageData = await chat.getMessageData(scene.channel) + scene.messages = ref(messageData.data) + + scene.unread = computed({ + get: () => messageData.unread, + set: (value) => { + send('im/v1/channel/update-settings', { + login: chat.getLogin(), + cid: scene.channel.id, + }) + messageData.unread = value + }, + }) + + scene.pinned = computed({ + get: () => scene.friend.pinned || false, + set: (value) => { + scene.friend.pinned = value + // TODO: + }, + }) + + const manualBrief = ref('') + scene.brief = computed({ + get: () => + manualBrief.value || + scene.messages?.value[scene.messages!.value.length - 1]?.content! || + '', + set: (value) => (manualBrief.value = value), + }) + scene.avatar = scene.friend.user.avatar + }, + onMount: async (scene) => { + scene.unread = -1 // HACK: cannot set unread to 0 twice. + }, + onUnmount: async (scene) => { + scene.unread = 0 + }, + }) + Scene.register('chat-guild', ChatScene, AsideGuild, { + onInit: async (scene) => { + const messageData = await chat.getMessageData(scene.channel) + scene.messages = ref(messageData.data) + + scene.unread = computed({ + get: () => messageData.unread, + set: (value) => { + send('im/v1/channel/update-settings', { + login: chat.getLogin(), + cid: scene.channel.id, + }) + messageData.unread = value + }, + }) + + scene.pinned = computed({ + get: () => scene.guild.settings?.[0].pinned || false, + set: (value) => { + if (scene.guild.settings?.[0].pinned) { + scene.guild.settings[0].pinned = value + } + // TODO: + }, + }) + + const manualBrief = ref('') + scene.brief = computed({ + get: () => + manualBrief.value || + scene.messages?.value[scene.messages!.value.length - 1]?.content! || + '', + set: (value) => (manualBrief.value = value), + }) + + scene.avatar = scene.guild.avatar + }, + onMount: async (scene) => { + scene.unread = -1 // HACK: cannot set unread to 0 twice. + }, + onUnmount: async (scene) => { + scene.unread = 0 + }, + }) + Scene.register('edit-user', MyInfoScene) + Scene.register('search', GlobalSearchScene) + Scene.register('msg-box', Native.MsgBoxScene) + Scene.register('create-guild', Native.CreateGuildScene) + }) +} diff --git a/packages/im/client/app/index.vue b/packages/im/client/app/index.vue new file mode 100644 index 0000000..ddf3f22 --- /dev/null +++ b/packages/im/client/app/index.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/packages/im/client/app/messenger/bubble.vue b/packages/im/client/app/messenger/bubble.vue new file mode 100644 index 0000000..2faf676 --- /dev/null +++ b/packages/im/client/app/messenger/bubble.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/im/client/app/messenger/chat.vue b/packages/im/client/app/messenger/chat.vue new file mode 100644 index 0000000..0c759e1 --- /dev/null +++ b/packages/im/client/app/messenger/chat.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/im/client/app/messenger/editor/echolink.ts b/packages/im/client/app/messenger/editor/echolink.ts new file mode 100644 index 0000000..196b646 --- /dev/null +++ b/packages/im/client/app/messenger/editor/echolink.ts @@ -0,0 +1,152 @@ +import { Dict } from '@cordisjs/client' + +type KeyCallback = (event: KeyboardEvent) => void +type InputCallback = (event: InputEvent) => void + +interface Combo { + ctrl: boolean + alt: boolean + key: string +} + +interface Hold { + duration: number + key: string +} + +// TODO: +class EchoLink { + private keyDownCallbacks: Dict = {} + private keyUpCallbacks: Dict = {} + private keyHoldCallbacks: Dict = {} + private keyComboCallbacks: Dict = {} + private beforeInputCallback: Dict = {} + private inputCallback: InputCallback = () => {} + private ordinaryCallbacks: Dict = {} + + private _isMonitoring = false + private _noSpread = false + private _noDefault = false + + get input() { + return this._handleInput + } + + get keydown() { + return this._handleKeydown + } + + get keyup() { + return this._handleKeyup + } + + constructor(private target: HTMLElement) {} + + start(name?: string) { + if (name === 'beforeinput') { + this.target.addEventListener('beforeinput', this._beforeInput) + return + } + + if (!this._isMonitoring) { + this.target.addEventListener('keydown', this._handleKeydown) + this.target.addEventListener('beforeinput', this._beforeInput) + this.target.addEventListener('input', this._handleInput as any) + this.target.addEventListener('keyup', this._handleKeyup) + this._isMonitoring = true + console.log('EchoLink activated.') + } + } + + stop(name: string, type?: string) { + if (name === 'beforeinput') { + this.target.removeEventListener('beforeinput', this._beforeInput) + return + } + + this.target.removeEventListener('keydown', this._handleKeydown) + this.target.removeEventListener('beforeinput', this._beforeInput) + this.target.removeEventListener('input', this._handleInput as any) + this.target.removeEventListener('keyup', this._handleKeyup) + this._isMonitoring = false + console.log('EchoLink deactivated.') + } + + getKey(key: string) { + return { + down: this.keyDownCallbacks[key], + up: this.keyUpCallbacks[key], + hold: this.keyHoldCallbacks[key], + } + } + + on(name: string, callback: (...args: any[]) => any) { + this.ordinaryCallbacks[name] = callback + } + + onBeforeInput(type: string, callback: InputCallback) { + this.beforeInputCallback[type] = callback + } + + onInput(callback: InputCallback) { + this.inputCallback = callback + } + + onKeyDown(key: string, callback: KeyCallback) { + this.keyDownCallbacks[key] = callback + } + + onKeyUp(key: string, callback: KeyCallback) { + this.keyUpCallbacks[key] = callback + } + + onKeyHold(key: string, callback: KeyCallback) { + this.keyHoldCallbacks[key] = callback + } + + onCombo(keys: string[], callback: KeyCallback) { + const comboKey = this.generateComboKey(keys) + this.keyComboCallbacks[comboKey] = callback + } + + generateComboKey(keys: string[]): string { + return keys.sort().join('+') + } + + _handleKeydown = (event: KeyboardEvent) => { + const key = event.key + const callback = this.keyDownCallbacks[key] + if (callback) { + callback(event) + } + } + + _handleKeyup = (event: KeyboardEvent) => { + const key = event.key + const callback = this.keyUpCallbacks[key] + if (callback) { + callback(event) + } + } + + _handleInput = (event: InputEvent) => { + if (this._noSpread) { + event.stopPropagation() + } + if (this._noDefault) { + event.preventDefault() + } + + this.inputCallback(event) + } + + _beforeInput = (event: InputEvent) => { + const callback = this.beforeInputCallback[event.inputType] || this.beforeInputCallback['*'] + + if (callback) { + callback(event) + } + } +} + +export default EchoLink diff --git a/packages/im/client/app/messenger/editor/editor.ts b/packages/im/client/app/messenger/editor/editor.ts new file mode 100644 index 0000000..0379f8f --- /dev/null +++ b/packages/im/client/app/messenger/editor/editor.ts @@ -0,0 +1,533 @@ +import { ref, VNode, nextTick, h } from 'vue' +import { debounce } from '../../../utils' +import EchoLink from './echolink' +import TextResolver from './resolver' + +type Caret = { + line: Range + text: Range + _node?: Range // HACK: _node will not update like Caret.line & Caret.text + _offset?: Range +} +type Range = { start: T; end: T } + +function initCaret(): Caret { + return { + line: { + start: 0, + end: 0, + }, + text: { + start: 0, + end: 0, + }, + } +} + +function isBlockElement(node: Node): boolean { + return node instanceof HTMLElement && node.classList.contains('markdown-block') +} + +function isInlineElement(node: Node): boolean { + return node instanceof HTMLElement && node.classList.contains('markdown-inline') +} + +function _isInline(inline: Node, node: Node): boolean { + return inline.contains(node) || inline === node +} + +function _isInBlock(block: Node, node: Node): boolean { + return block.contains(node) || block === node +} + +function _isBlockAtLastLine(root: Node, node: Node): boolean { + return node.parentElement === root && (root.lastChild?.contains(node) || root.lastChild === node) +} + +export class Editor { + keyListener: EchoLink + mdResolver: TextResolver + + _raws: string[] = [''] + _caret: Caret = initCaret() + cursorOffset: number = 0 + + composition = { + composing: false, + text: '', + } + + result = ref([]) + + get raw(): string { + return this._raws.join('\n') + } + + get caret(): Caret { + this.secureCaret() + return this._caret + } + + get _lineElements(): Node[] { + return Array.from(this.ref.childNodes).filter((node) => node.nodeName === 'DIV') + } + + constructor(public ref: HTMLElement) { + this.keyListener = new EchoLink(ref) + this.mdResolver = new TextResolver(true) + + document.addEventListener( + 'selectionchange', + debounce(() => { + const selection = window.getSelection()! + + if ( + this.ref.contains(selection.getRangeAt(0).commonAncestorContainer) && + !this.composition.composing + ) { + this._caret = this.getCaret(selection)! + } + }, 100) + ) + + this.ref.addEventListener('paste', (event) => { + const text = event.clipboardData?.getData('text/plain') || '' + + const pos = this._caret + if (pos.line.start !== pos.line.end || pos.text.start !== pos.text.end) { + this.deleteInRaw() + } + + this.insertInRaw(text) + this._updateText() + event.preventDefault() + }) + + this.ref.addEventListener('cut', (event) => { + const selected = window.getSelection()?.toString()! + event.clipboardData?.setData('text/plain', selected) + this.deleteInRaw() + this._updateText() + event.preventDefault() + }) + + this.ref.addEventListener('compositionstart', async (event) => { + this.composition.composing = true + + event.preventDefault() + }) + + this.ref.addEventListener('compositionupdate', (event) => { + this.composition.text = event.data + }) + + this.ref.addEventListener('compositionend', (event) => { + this.insertInRaw(this.composition.text || '') + + this.composition.composing = false + this.composition.text = '' + + this._updateText() + }) + + this.keyListener.onBeforeInput('insertCompositionText', (event) => {}) + + this.keyListener.onBeforeInput('deleteContentBackward', (event) => { + this.deleteInRaw() + this._updateText() + event.preventDefault() + }) + + this.keyListener.onBeforeInput('insertParagraph', (event) => { + this.insertInRaw('\n') + this._updateText() + event.preventDefault() + }) + + this.keyListener.onBeforeInput('*', (event) => { + const pos = this._caret + + if (pos.line.start !== pos.line.end || pos.text.start !== pos.text.end) { + this.deleteInRaw() + } + + if (!this.composition.composing) { + this.insertInRaw(event.data || '') + } + + this._updateText() + + event.preventDefault() + }) + + this.keyListener.onInput((event) => { + console.warn(event) + + this.resetCaret() + }) + + this.keyListener.onKeyDown('Tab', (event) => { + this.insertInRaw(' ') + this._updateText() + event.preventDefault() + }) + + this.keyListener.start() + + this._updateText().then(() => { + this.resetCaret() + }) + } + + editorWalker = (root: Node) => { + let inlineStack: Node[] = [] + + return document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + (node) => { + const currentInline = inlineStack[inlineStack.length - 1] + let flag: number = NodeFilter.FILTER_SKIP + if (node.nodeType === Node.ELEMENT_NODE) { + if (isInlineElement(node)) { + inlineStack.push(node) + } + + flag = NodeFilter.FILTER_ACCEPT + } else if (node.nodeType === Node.TEXT_NODE) { + if (currentInline && _isInline(currentInline, node)) { + flag = NodeFilter.FILTER_ACCEPT + } else if (!this.composition.composing && node.textContent) { + console.warn('illegal element detected') + node.textContent = '' + flag = NodeFilter.FILTER_REJECT + } + + if (currentInline && currentInline.lastChild === node) { + console.log(node) + inlineStack.splice(-1) + } + } + + return flag + } + ) + } + + // This operation will trigger asynchronous Vue DOM updates. After calling this method, + // nextTick() must be called to ensure that the DOM has been updated before performing + async _updateText() { + this.result.value = this.mdResolver.resolveContent(this.raw) + await nextTick() + } + + resetContent() { + this._raws = [''] + + this._updateText() + this._caret = initCaret() + } + + getCaret(selection: Selection): Caret | null { + if (!selection) return null + + function reorderSelection(selection: Selection) { + const anchorNode = selection.anchorNode + const anchorOffset = selection.anchorOffset + const focusNode = selection.focusNode + const focusOffset = selection.focusOffset + + const isAnchorBeforeFocus = + anchorNode === focusNode + ? anchorOffset < focusOffset + : (document.compareDocumentPosition(anchorNode!) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 + + if (!isAnchorBeforeFocus) { + return { + startNode: focusNode, + startOffset: focusOffset, + endNode: anchorNode, + endOffset: anchorOffset, + } + } + + return { + startNode: anchorNode, + startOffset: anchorOffset, + endNode: focusNode, + endOffset: focusOffset, + } + } + + const reordered = reorderSelection(selection) + + const result: Caret = { + line: { + start: this._lineElements.length - 1, + end: this._lineElements.length - 1, + }, + text: { + start: 0, + end: 0, + }, + _node: { + start: reordered.startNode!, + end: reordered.endNode!, + }, + _offset: { + start: reordered.startOffset, + end: reordered.endOffset, + }, + } + + const walker = this.editorWalker(this.ref) + let current: Node | null = null + let lastBlock: Node | null = null + let count = 0 + + let flag = [false, false] + while ((current = walker.nextNode()) && !flag.every(Boolean)) { + if ( + lastBlock && + !_isInBlock(lastBlock, current) && + !_isBlockAtLastLine(this.ref, lastBlock) + ) { + count += 1 + lastBlock = null + } + + if (isBlockElement(current)) { + lastBlock = current + } + + if (current.nodeType === Node.TEXT_NODE) { + if (current === result._node!.start) { + result.text.start = count + result._offset!.start + flag[0] = true + } + if (current === result._node!.end) { + result.text.end = count + result._offset!.end + flag[1] = true + } + + count += current.textContent?.length || 0 + } + } + + result.line.start = 0 + result.line.end = 0 + + console.log(`Recorded caret. At: ${JSON.stringify(result)}`) + + return result + } + + resetCaret() { + this.setCaret(this._caret.line, this._caret.text) + } + + setCaret(line: Range, offset: Range) { + this.secureCaret(line, offset) + + const range = document.createRange() + const selection = window.getSelection() + if (!selection) return + + const absolute = (line: number, offset: number): number => + this._raws.slice(0, line).reduce((sum, str) => sum + str.length, 0) + offset + + const start = this._walkLine(this.ref, absolute(line.start, offset.start)) + const end = + line.start === line.end && offset.start === offset.end + ? start + : this._walkLine(this.ref, absolute(line.end, offset.end)) + + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + + selection.removeAllRanges() + selection.addRange(range) + + console.log(`Caret settled. line: ${line.start}, offset: ${offset.start}`) + } + + deleteInRaw() { + const lineStart = this.caret.line.start + const lineEnd = this.caret.line.end + const offsetStart = this.caret.text.start + const offsetEnd = this.caret.text.end + const rawArray = this._raws + + // multiple selection + if (offsetStart !== offsetEnd || lineStart !== lineEnd) { + if (lineStart !== lineEnd) { + rawArray[lineStart] = rawArray[lineStart].slice(0, offsetStart) + rawArray[lineEnd] = rawArray[lineEnd].slice(offsetEnd) + rawArray.splice(lineStart + 1, lineEnd - lineStart - 1) + } else { + rawArray[lineStart] = + rawArray[lineStart].slice(0, offsetStart) + rawArray[lineStart].slice(offsetEnd) + } + + this.caret.text.start = this.caret.text.end = offsetStart + this.caret.line.start = this.caret.line.end = lineStart + return + } + + // single selection + if (offsetStart <= 0) { + if (lineStart === 0) return + + const previous = lineStart - 1 + this.caret.text.start = this.caret.text.end = rawArray[previous].length + rawArray[previous] += rawArray[lineStart] + rawArray.splice(lineStart, 1) + this.caret.line.start = this.caret.line.end = previous + } else { + if (offsetStart === rawArray[lineStart].length) { + rawArray[lineStart] = rawArray[lineStart].slice(0, offsetStart - 1) + } else { + rawArray[lineStart] = + rawArray[lineStart].slice(0, offsetStart - 1) + rawArray[lineStart].slice(offsetStart) + } + this.caret.text.end = this.caret.text.start -= 1 + } + } + + insertInRaw(text: string) { + const index = this.caret.line.start + const offset = this.caret.text.start + const rawArray = this._raws + + const beforeText = rawArray[index].slice(0, offset) + const afterText = rawArray[index].slice(offset) + + rawArray[index] = beforeText + text + afterText + + console.log(this._raws) + + this.moveCaret({ x: text.length }) + } + + // This method could parse abstractive text line to concrete location of node. + // values must be ensured to be safe, or call secureCaret() before the method. + _walkLine(line: number, offset: number): any + _walkLine(root: Node, offset: number): any + _walkLine(lineOrNode: number | Node, offset: number) { + const root = typeof lineOrNode === 'number' ? this._lineElements[lineOrNode] : lineOrNode + const walker = this.editorWalker(root) + + let count = 0 + let current: Node | null = null + let lastBlock: Node | null = null + while ((current = walker.nextNode())) { + if ( + lastBlock && + !_isInBlock(lastBlock, current) && + !_isBlockAtLastLine(this.ref, lastBlock) + ) { + count += 1 + lastBlock = null + } + + if (isBlockElement(current)) { + lastBlock = current + } + + if (current.nodeType === Node.TEXT_NODE) { + const length = current.textContent?.length || 0 + + if (offset <= length + count) { + break + } + + count += length + } + } + + if (!current) { + current = root + // while (root.lastChild) { + // current = root.lastChild + // } + } + + return { + node: current, + offset: Math.min(offset - count, current.textContent?.length || Number.MAX_SAFE_INTEGER), + } + } + + moveCaret(offset: { y?: number; x?: number }) { + let caret = this._caret + let y = caret.line.start + (offset.y || 0) + let x = caret.text.start + (offset.x || 0) + + const yLength = this._raws.length + if (y < 0) { + y = 0 + x = 0 + } else if (y >= yLength) { + y = yLength - 1 + x = this._raws[y].length + } + + let xLength = this._raws[y].length + + while (offset.x && (x < 0 || x > xLength)) { + const direction = Math.sign(x) + + y += direction + if (y < 0) { + y = 0 + x = 0 + break + } else if (y >= yLength) { + y = yLength - 1 + x = xLength + break + } + + const next = this._raws[y].length + + if (x < 0) { + x -= (next + 1) * direction + } else { + x -= (xLength + 1) * direction + } + + xLength = next + } + + caret.line.start = y + caret.line.end = caret.line.start + caret.text.start = x + caret.text.end = caret.text.start + console.log(`Caret moved. y: ${offset.y}, x: ${offset.x}`) + } + + secureCaret(): void + secureCaret(y: Range, x: Range): void + secureCaret(y?: Range, x?: Range) { + const yLength = this._raws.length - 1 + + y || (y = this._caret.line) + x || (x = this._caret.text) + + y.start = secure(y.start, yLength) + y.end = secure(y.end, yLength) + + const xStartLength = this._raws[y.start].length + const xEndLength = this._raws[y.end].length + x.start = secure(x.start, xStartLength) + x.end = secure(x.end, xEndLength) + + function secure(index: number, length: number): number { + if (index >= 0 || index <= length) { + return index + } + console.warn('out of range') + return index < 0 ? 0 : length + } + } +} diff --git a/packages/im/client/app/messenger/editor/editor.vue b/packages/im/client/app/messenger/editor/editor.vue new file mode 100644 index 0000000..3f77248 --- /dev/null +++ b/packages/im/client/app/messenger/editor/editor.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/packages/im/client/app/messenger/editor/index.ts b/packages/im/client/app/messenger/editor/index.ts new file mode 100644 index 0000000..dfb898b --- /dev/null +++ b/packages/im/client/app/messenger/editor/index.ts @@ -0,0 +1,2 @@ +export * from './editor' +export { default as ImEditor } from './editor.vue' diff --git a/packages/im/client/app/messenger/editor/md-rules.ts b/packages/im/client/app/messenger/editor/md-rules.ts new file mode 100644 index 0000000..7b2d559 --- /dev/null +++ b/packages/im/client/app/messenger/editor/md-rules.ts @@ -0,0 +1,71 @@ +import { h, VNode, resolveComponent } from 'vue' +import { + Lexer, + marked, + Tokens, + TokenizerExtensionFunction, + Tokenizer, + TokenizerObject, +} from 'marked' + +declare module 'marked' { + namespace Tokens { + interface Component { + type: 'component' + raw: string + text: string + } + + interface At { + type: 'at' + raw: string + name: string + } + } +} + +export class ImTokenizer extends Tokenizer { + constructor() { + super() + } +} + +export const componentRule: TokenizerExtensionFunction = function ( + this, + src +): Tokens.Component | undefined { + const match = src.match(/\/(\{[^}]*\});/) + if (match) { + return { + type: 'component', + raw: match[0], + text: match[1], + } as any + } +} + +export const atRule: TokenizerExtensionFunction = function (this, src): Tokens.At | undefined { + const match = src.match(/^@([^\s\n]*)/) + + if (match) { + return { + type: 'at', + raw: match[0], + name: match[1], + } + } +} + +/* + FIXME: The ReturnType here is expected to ReturnType, + but should actually be number. +*/ +// @ts-ignore +export const atFinder: TokenizerExtensionFunction = function (this, src) { + const mentionPattern = /@([a-zA-Z0-9_]+)/g + let match = mentionPattern.exec(src) + if (match) { + return match.index + } + return Infinity +} diff --git a/packages/im/client/app/messenger/editor/resolver.ts b/packages/im/client/app/messenger/editor/resolver.ts new file mode 100644 index 0000000..89fff81 --- /dev/null +++ b/packages/im/client/app/messenger/editor/resolver.ts @@ -0,0 +1,140 @@ +import { h, VNode, resolveComponent } from 'vue' +import { + Lexer, + marked, + Token, + TokenizerExtensionFunction, + Tokens, + walkTokens, + TokensList, +} from 'marked' +import { Element } from '@satorijs/core' +import { + MdCodeBlock, + MdCodeSpan, + MdEmphasis, + MdList, + MdSpace, + MdParagraph, + MdHeading, + MdStrong, + MdText, + At, +} from '../message' +import { ImTokenizer, atRule, atFinder } from './md-rules' +import { genId } from '@satorijs/plugin-im-utils' + +interface Renderer { + default: (token: T) => VNode + editor: (token: T) => VNode + compliant: (token: T) => Element +} + +const renderers: Renderer[] = [] +const inlinePlugins: TokenizerPlugin[] = [] +const blockPlugins: TokenizerPlugin[] = [] + +interface TokenizerPlugin { + rule: TokenizerExtensionFunction + locator: TokenizerExtensionFunction | undefined +} + +class TextResolver { + _inlinePlugins: TokenizerExtensionFunction[] = [atRule] + _blockPlugins: TokenizerExtensionFunction[] = [] + + get _tokenizer() { + return (text: string): Token[] => { + return marked.lexer(text, { + tokenizer: new ImTokenizer(), + extensions: { + inline: this._inlinePlugins, + block: this._blockPlugins, + startInline: [atFinder], + } as any, + }) + } + } + + constructor(public editMode: boolean) {} + + withInlinePlugin(...plugins: TokenizerExtensionFunction[]) { + this._inlinePlugins.push(...plugins) + } + + withBlockPlugin(...plugins: TokenizerExtensionFunction[]) { + this._blockPlugins.push(...plugins) + } + + resolveLine(content: string): VNode { + console.log(this._tokenizer(content)) + return h('div', this._resolveChildren(this._tokenizer(content))) + } + + resolveContent(content: string): VNode[] { + console.log(this._tokenizer(content)) + console.log(this._resolveChildren(this._tokenizer(content))) + return this._resolveChildren(this._tokenizer(content)) + } + + resolveToken(token: Token): VNode | null { + if (token.type === 'at') { + return h(At, { token }) + } else if (token.type === 'component') { + console.log(token) + const obj = JSON.parse(token.text) + const { name, ...args } = obj + if (!resolveComponent(name)) { + h('text', token.text) + } + return h(resolveComponent(name), { ...args }) + } else if (token.type === 'code') { + return h(MdCodeBlock, { token }) + } else if (token.type === 'codespan') { + return h(MdCodeSpan, { text: token.raw }) + } else if (token.type === 'paragraph') { + // HACK: Use genId() to force an update to resolve the IME problem. + return h(MdParagraph, { token, children: this._resolveChildren(token.tokens!) }) + } else if (token.type === 'image') { + return h(resolveComponent('md-image'), { src: token.href }) + } else if (token.type === 'blockquote') { + return h('blockquote', this._resolveChildren(token.tokens!)) // TODO + } else if (token.type === 'text') { + return h(MdText, { text: token.text }) + } else if (token.type === 'em') { + const children = this._resolveChildren(token.tokens!) + return h(MdEmphasis, { token, children }) + } else if (token.type === 'strong') { + const children = this._resolveChildren(token.tokens!) + return h(MdStrong, { token, children }) + } else if (token.type === 'del') { + return h('del', this._resolveChildren(token.tokens!)) + } else if (token.type === 'link') { + return h('a', { href: token.href }, this._resolveChildren(token.tokens!)) + } else if (token.type === 'space') { + return h(MdSpace, { text: token.raw }) + } else if (token.type === 'list') { + return h(MdList, { token }) + } else if (token.type === 'heading') { + return h(MdHeading, { token, children: this._resolveChildren(token.tokens!) }) + } + return h('text', token.raw) + } + + _resolveChildren(tokens: Token[]): VNode[] { + if (!tokens.length) { + return [] + } + return tokens.map((token) => this.resolveToken(token)).filter(Boolean) as VNode[] + } +} + +export function registerRenderer(renderer: Renderer) { + renderers.push(renderer) +} + +export function registerInlineTokenizer(tokenizer: TokenizerPlugin) {} + +export function registerBlockTokenizer(tokenizer: TokenizerPlugin) {} + +export default TextResolver diff --git a/packages/im/client/app/messenger/index.ts b/packages/im/client/app/messenger/index.ts new file mode 100644 index 0000000..8c5381e --- /dev/null +++ b/packages/im/client/app/messenger/index.ts @@ -0,0 +1,128 @@ +import { Context, send } from '@cordisjs/client' +import type { Im } from '@satorijs/plugin-im' +import transform from './message' +import MdImage from './message/image.vue' +import MdInlineElement from './message/md-inline.vue' +import MdBlockElement from './message/md-block.vue' +import MdUnknown from './message/unknown.vue' + +export { default as ChatScene } from './chat.vue' + +declare module '@cordisjs/client' { + interface ActionContext { + message: Im.Message + } +} + +declare module '@satorijs/plugin-im' { + namespace Im { + interface CombinedMessages { + user: User + messages: Array + lastTimestamp: number + member?: Member + roles?: Array + } + } +} + +class Messenger { + private tempIdCounter: bigint = BigInt(0) + private static _instance: Messenger + + static get instance(): Messenger { + if (!Messenger._instance) { + this._instance = new Messenger() + } + return this._instance + } + + private constructor() {} + + rendMessage = transform + + genTempId(): string { + this.tempIdCounter += BigInt(1) + return this.tempIdCounter.toString() + } +} + +export const imMessenger = Messenger.instance + +export default function (ctx: Context) { + ctx.menu('message', [ + { + id: '.copy', + icon: 'im:copy', + label: '复制文本', + }, + { + id: 'quote', + icon: 'im:quote', + label: '回复', + }, + { + id: 'share', + icon: 'im:share', + label: '分享', + }, + { + id: 'select', + icon: 'im:check-round', + label: '多选', + }, + { + id: '@separator', + }, + { + id: '.recall', + icon: 'im:recall', + label: '撤回', + }, + { + id: '@separator', + }, + { + id: '.delete', + icon: 'delete', + label: '删除', + }, + ]) + + ctx.inject(['im.client'], (injected) => { + const chat = injected['im.client'] + injected.action('message.copy', ({ message }) => { + navigator.clipboard.writeText(message.content!) + }) + injected.action('message.quote', ({ message }) => { + // TODO: + }) + injected.action('message.share', ({ message }) => { + // TODO: + }) + injected.action('message.select', ({ message }) => { + // TODO: select mode + }) + injected.action('message.recall', { + hidden: ({ message }) => { + const me = chat.getLogin().user! + return !(message.user!.id === me.id) + }, + action: ({ message }) => { + send('im/v1/message/recall', { + login: chat.getLogin(), + cid: message.channel!.id, + mid: message.id!, + }) + }, + }) + injected.action('message.delete', ({ message }) => { + // send('im/v1/message/settings') + }) + }) + + ctx.app.component('md-block', MdBlockElement) + ctx.app.component('md-image', MdImage) + ctx.app.component('md-inline', MdInlineElement) + ctx.app.component('md-unknown', MdUnknown) +} diff --git a/packages/im/client/app/messenger/message-item.vue b/packages/im/client/app/messenger/message-item.vue new file mode 100644 index 0000000..9040dd3 --- /dev/null +++ b/packages/im/client/app/messenger/message-item.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/im/client/app/messenger/message/at.vue b/packages/im/client/app/messenger/message/at.vue new file mode 100644 index 0000000..1e6b970 --- /dev/null +++ b/packages/im/client/app/messenger/message/at.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/im/client/app/messenger/message/break.vue b/packages/im/client/app/messenger/message/break.vue new file mode 100644 index 0000000..ec65b9e --- /dev/null +++ b/packages/im/client/app/messenger/message/break.vue @@ -0,0 +1,5 @@ + diff --git a/packages/im/client/app/messenger/message/code-block.vue b/packages/im/client/app/messenger/message/code-block.vue new file mode 100644 index 0000000..791051e --- /dev/null +++ b/packages/im/client/app/messenger/message/code-block.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/im/client/app/messenger/message/code-span.vue b/packages/im/client/app/messenger/message/code-span.vue new file mode 100644 index 0000000..98baa5e --- /dev/null +++ b/packages/im/client/app/messenger/message/code-span.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/packages/im/client/app/messenger/message/emphasis.vue b/packages/im/client/app/messenger/message/emphasis.vue new file mode 100644 index 0000000..02259cb --- /dev/null +++ b/packages/im/client/app/messenger/message/emphasis.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/im/client/app/messenger/message/heading.vue b/packages/im/client/app/messenger/message/heading.vue new file mode 100644 index 0000000..98b126c --- /dev/null +++ b/packages/im/client/app/messenger/message/heading.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/packages/im/client/app/messenger/message/image.vue b/packages/im/client/app/messenger/message/image.vue new file mode 100644 index 0000000..b4500b8 --- /dev/null +++ b/packages/im/client/app/messenger/message/image.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/im/client/app/messenger/message/index.ts b/packages/im/client/app/messenger/message/index.ts new file mode 100644 index 0000000..abc1df7 --- /dev/null +++ b/packages/im/client/app/messenger/message/index.ts @@ -0,0 +1,88 @@ +import { h, VNode, resolveComponent } from 'vue' +import { marked, Token, TokenizerExtensionFunction } from 'marked' + +export { Token, Tokens as TokenType } from 'marked' + +export { default as MdEmphasis } from './emphasis.vue' +export { default as MdStrong } from './strong.vue' +export { default as MdImage } from './image.vue' +export { default as MdCodeSpan } from './code-span.vue' +export { default as MdCodeBlock } from './code-block.vue' +export { default as MdList } from './list.vue' +export { default as MdSpace } from './space.vue' +export { default as MdParagraph } from './paragraph.vue' +export { default as MdHeading } from './heading.vue' +export { default as At } from './at.vue' +export { default as MdText } from './text.vue' + +const tagRegex = /^<(\/?)([^!\s>/]+)([^>]*?)\s*(\/?)>$/ + +const component: TokenizerExtensionFunction = function (this, src) { + const match = src.match(/\/(\{[^}]*\});/) + if (match) { + return { + type: 'component', + raw: match[0], + text: match[1], + } as any + } + + return false +} + +function rendChildren(tokens: Token[]): VNode[] | undefined { + if (!tokens) { + console.log('no tokens') + return undefined + } + return tokens.map(renderToken).filter(Boolean) +} + +function renderToken(token: Token): VNode { + if (token.type === 'component') { + console.log(token) + const obj = JSON.parse(token.text) + const { name, ...args } = obj + if (!resolveComponent(name)) { + h('text', token.text) + } + return h(resolveComponent(name), { ...args }) + } else if (token.type === 'code') { + return h('code', token.text + '\n') + } else if (token.type === 'codespan') { + return h('code', { text: token.raw }) + } else if (token.type === 'paragraph') { + return h('div', rendChildren(token.tokens!)) + } else if (token.type === 'image') { + return h(resolveComponent('md-image'), { src: token.href }) + } else if (token.type === 'blockquote') { + return h('blockquote', rendChildren(token.tokens!)) + } else if (token.type === 'text') { + return h('text', token.text) + } else if (token.type === 'em') { + return h('em', rendChildren(token.tokens!)) + } else if (token.type === 'strong') { + return h('b', rendChildren(token.tokens!)) + } else if (token.type === 'del') { + return h('del', rendChildren(token.tokens!)) + } else if (token.type === 'link') { + return h('a', { href: token.href }, rendChildren(token.tokens!)) + } + return h('div', token.raw) +} + +export const tokenizer = (content: string): Token[] => + marked.lexer(content, { + extensions: { + inline: [component], + } as any, + }) + +export function renderer(tokens: Token[]): VNode[] { + return tokens.map(renderToken).filter(Boolean) +} + +export default function transform(content: string): VNode[] { + if (!content) return [h('text')] + return renderer(tokenizer(content)) +} diff --git a/packages/im/client/app/messenger/message/list.vue b/packages/im/client/app/messenger/message/list.vue new file mode 100644 index 0000000..3d1c090 --- /dev/null +++ b/packages/im/client/app/messenger/message/list.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/im/client/app/messenger/message/md-block.vue b/packages/im/client/app/messenger/message/md-block.vue new file mode 100644 index 0000000..dc3e255 --- /dev/null +++ b/packages/im/client/app/messenger/message/md-block.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/im/client/app/messenger/message/md-inline.vue b/packages/im/client/app/messenger/message/md-inline.vue new file mode 100644 index 0000000..b0c5b85 --- /dev/null +++ b/packages/im/client/app/messenger/message/md-inline.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/im/client/app/messenger/message/paragraph.vue b/packages/im/client/app/messenger/message/paragraph.vue new file mode 100644 index 0000000..40f2261 --- /dev/null +++ b/packages/im/client/app/messenger/message/paragraph.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/im/client/app/messenger/message/space.vue b/packages/im/client/app/messenger/message/space.vue new file mode 100644 index 0000000..c4b9a66 --- /dev/null +++ b/packages/im/client/app/messenger/message/space.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/im/client/app/messenger/message/strong.vue b/packages/im/client/app/messenger/message/strong.vue new file mode 100644 index 0000000..b5e15a7 --- /dev/null +++ b/packages/im/client/app/messenger/message/strong.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/im/client/app/messenger/message/text.vue b/packages/im/client/app/messenger/message/text.vue new file mode 100644 index 0000000..e19dd60 --- /dev/null +++ b/packages/im/client/app/messenger/message/text.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/im/client/app/messenger/message/unknown.vue b/packages/im/client/app/messenger/message/unknown.vue new file mode 100644 index 0000000..11807f9 --- /dev/null +++ b/packages/im/client/app/messenger/message/unknown.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/im/client/app/messenger/resolver/index.ts b/packages/im/client/app/messenger/resolver/index.ts new file mode 100644 index 0000000..c4b38ed --- /dev/null +++ b/packages/im/client/app/messenger/resolver/index.ts @@ -0,0 +1,24 @@ +import { h, VNode, resolveComponent } from 'vue' +import { + Lexer, + marked, + Token, + TokenizerExtensionFunction, + Tokens, + walkTokens, + TokensList, +} from 'marked' +import { Context } from '@satorijs/core' +import { + MdCodeBlock, + MdCodeSpan, + MdList, + MdSpace, + MdParagraph, + MdHeading, + MdText, + At, +} from '../message' +import { genId } from '@satorijs/plugin-im-utils' + +export default function (ctx: Context) {} diff --git a/packages/im/client/app/messenger/resolver/resolver.ts b/packages/im/client/app/messenger/resolver/resolver.ts new file mode 100644 index 0000000..377ceb5 --- /dev/null +++ b/packages/im/client/app/messenger/resolver/resolver.ts @@ -0,0 +1,152 @@ +import { h, VNode, resolveComponent } from 'vue' +import { + Lexer, + marked, + Token, + TokenizerExtensionFunction, + Tokens, + walkTokens, + TokensList, +} from 'marked' +import { Element } from '@satorijs/core' +import { + MdCodeBlock, + MdCodeSpan, + MdList, + MdSpace, + MdParagraph, + MdHeading, + MdText, + At, +} from '../message' +import { ImTokenizer, atRule } from './rules' +import { genId } from '@satorijs/plugin-im-utils' + +export type Renderer = (token: T) => R + +export interface RendererPlugin { + type: string + default: Renderer + editor: Renderer + satori: Renderer +} + +export interface TokenizerPlugin { + rule: TokenizerExtensionFunction + locator?: TokenizerExtensionFunction +} + +const renderers: RendererPlugin[] = [] +const inlinePlugins: TokenizerPlugin[] = [] +const blockPlugins: TokenizerPlugin[] = [] + +class TextResolver { + _inlinePlugins: TokenizerExtensionFunction[] = [] + _blockPlugins: TokenizerExtensionFunction[] = [] + + get _tokenizer() { + return (text: string): Token[] => { + if (!text) + return [ + { + type: 'paragraph', + raw: '', + text: '', + }, + ] + return marked.lexer(text, { + tokenizer: new ImTokenizer(), + extensions: { + inline: this._inlinePlugins, + block: this._blockPlugins, + startInline: [], + } as any, + }) + } + } + + constructor(public editMode: boolean) {} + + withInlinePlugin(...plugins: TokenizerExtensionFunction[]) { + this._inlinePlugins.push(...plugins) + } + + withBlockPlugin(...plugins: TokenizerExtensionFunction[]) { + this._blockPlugins.push(...plugins) + } + + resolveLine(content: string): VNode { + console.log(this._tokenizer(content)) + console.log(marked(content)) + return h('div', this._resolveChildren(this._tokenizer(content))) + } + + resolveContent(content: string): VNode[] { + console.log(this._tokenizer(content)) + console.log(marked(content)) + console.log(this._resolveChildren(this._tokenizer(content))) + return this._resolveChildren(this._tokenizer(content)) + } + + resolveToken(token: Token): VNode | null { + if (token.type === 'at') { + return h(At, { token }) + } else if (token.type === 'component') { + console.log(token) + const obj = JSON.parse(token.text) + const { name, ...args } = obj + if (!resolveComponent(name)) { + h('text', token.text) + } + return h(resolveComponent(name), { ...args }) + } else if (token.type === 'code') { + return h(MdCodeBlock, { token }) + } else if (token.type === 'codespan') { + return h(MdCodeSpan, { text: token.raw }) + } else if (token.type === 'paragraph') { + // HACK: Use genId() to force an update to resolve the IME problem. + return h(MdParagraph, { token, children: this._resolveChildren(token.tokens!) }) + } else if (token.type === 'image') { + return h(resolveComponent('md-image'), { src: token.href }) + } else if (token.type === 'blockquote') { + return h('blockquote', this._resolveChildren(token.tokens!)) // TODO + } else if (token.type === 'text') { + return h(MdText, { text: token.text }) + } else if (token.type === 'em') { + const children = this._resolveChildren(token.tokens!) + return h('em', this.editMode ? ['*', ...children, '*'] : children) + } else if (token.type === 'strong') { + const children = this._resolveChildren(token.tokens!) + return h('b', this.editMode ? ['**', ...children, '**'] : children) + } else if (token.type === 'del') { + return h('del', this._resolveChildren(token.tokens!)) + } else if (token.type === 'link') { + return h('a', { href: token.href }, this._resolveChildren(token.tokens!)) + } else if (token.type === 'space') { + return h(MdSpace, { text: token.raw }) + } else if (token.type === 'list') { + return h(MdList, { token }) + } else if (token.type === 'heading') { + return h(MdHeading, { token, children: this._resolveChildren(token.tokens!) }) + } + return h('text', token.raw) + } + + _resolveChildren(tokens: Token[]): VNode[] { + if (!tokens) { + console.log('no tokens') + return [] + } + return tokens.map((token) => this.resolveToken(token)).filter(Boolean) as VNode[] + } +} + +// export function registerRenderer(renderer: Renderer) { +// renderers.push(renderer) +// } + +export function registerInlineTokenizer(tokenizer: TokenizerPlugin) {} + +export function registerBlockTokenizer(tokenizer: TokenizerPlugin) {} + +export default TextResolver diff --git a/packages/im/client/app/messenger/resolver/rules.ts b/packages/im/client/app/messenger/resolver/rules.ts new file mode 100644 index 0000000..38f7978 --- /dev/null +++ b/packages/im/client/app/messenger/resolver/rules.ts @@ -0,0 +1,73 @@ +import { h, VNode, resolveComponent } from 'vue' +import { + Lexer, + marked, + Tokens, + TokenizerExtensionFunction, + Tokenizer, + TokenizerObject, +} from 'marked' +import { TokenizerPlugin, RendererPlugin } from './resolver' + +declare module 'marked' { + namespace Tokens { + interface Component { + type: 'component' + raw: string + text: string + } + + interface At { + type: 'at' + raw: string + name: string + } + } +} + +export class ImTokenizer extends Tokenizer { + constructor() { + super() + } +} + +export const componentRule: TokenizerPlugin = { + rule(this, src): Tokens.Component | undefined { + const match = src.match(/\/(\{[^}]*\});/) + if (match) { + return { + type: 'component', + raw: match[0], + text: match[1], + } as any + } + }, +} + +export const atRule: TokenizerPlugin = { + rule(this, src): Tokens.At | undefined { + const match = src.match(/^@([^\s\n]*)/) + + if (match) { + return { + type: 'at', + raw: match[0], + name: match[1], + } + } + }, + + /* + FIXME: The ReturnType here is expected to ReturnType, + but should actually be number. +*/ + // @ts-ignore + locator(this, src) { + const mentionPattern = /@([a-zA-Z0-9_]+)/g + let match = mentionPattern.exec(src) + if (match) { + return match.index + } + return Infinity + }, +} diff --git a/packages/im/client/app/messenger/resolver/templates.ts b/packages/im/client/app/messenger/resolver/templates.ts new file mode 100644 index 0000000..c5e6493 --- /dev/null +++ b/packages/im/client/app/messenger/resolver/templates.ts @@ -0,0 +1,27 @@ +import { h } from 'vue' +import { h as Element } from '@satorijs/core' +import { Tokens } from 'marked' +import { RendererPlugin } from './resolver' +import { + MdCodeBlock, + MdCodeSpan, + MdList, + MdSpace, + MdParagraph, + MdHeading, + MdText, + At, +} from '../message' + +export const headingRenderer: RendererPlugin = { + type: 'heading', + default(token) { + return h('heading') + }, + editor(token) { + return h(MdHeading, { token, children: this.recursive(token.tokens) }) + }, + satori(token) { + return Element('h') + }, +} diff --git a/packages/im/client/app/navigation/friend-item.vue b/packages/im/client/app/navigation/friend-item.vue new file mode 100644 index 0000000..4e745a2 --- /dev/null +++ b/packages/im/client/app/navigation/friend-item.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages/im/client/app/navigation/guild-item.vue b/packages/im/client/app/navigation/guild-item.vue new file mode 100644 index 0000000..bb64c79 --- /dev/null +++ b/packages/im/client/app/navigation/guild-item.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/im/client/app/navigation/index.ts b/packages/im/client/app/navigation/index.ts new file mode 100644 index 0000000..ce7e6cb --- /dev/null +++ b/packages/im/client/app/navigation/index.ts @@ -0,0 +1,85 @@ +import { Context } from '@cordisjs/client' +import Scene from '../scene' +export { default as ImNavigation } from './list.vue' + +declare module '@cordisjs/client' { + interface ActionContext { + 'new-chat': void + session: Scene.Prototype + } +} + +export default function (ctx: Context) { + ctx.menu('new-chat', [ + { + id: '.add', + icon: 'add', + label: '新好友/群聊', + }, + { + id: '.create-guild', + icon: 'im:group', + label: '创建群聊', + }, + ]) + + ctx.menu('session', [ + { + id: '.pin', + icon: 'im:pin', + label: '置顶', + }, + { + id: '.unpin', + icon: 'im:pin', + label: '取消置顶', + }, + { + id: '.readed', + icon: 'im:readed', + label: '设置已读', + }, + { + id: '@separator', + }, + { + id: '.close', + icon: 'close', + label: '关闭此会话', + }, + ]) + + // FIXME: 在执行 Scene.close 时,menu 组件似乎未销毁。 + // 这导致了 hidden 会在(大约)菜单消失时访问到一个为假值的 session。 + // 目前在 hidden 加入链式访问操作符,保证安全访问。 + ctx.action('session.pin', { + hidden: ({ session }) => { + return !!session?.pinned + }, + action: ({ session }) => { + session.pinned = true + }, + }) + ctx.action('session.unpin', { + hidden: ({ session }) => { + return !session?.pinned + }, + action: ({ session }) => { + session.pinned = false + }, + }) + ctx.action('session.readed', { + hidden: ({ session }) => { + return session?.id === Scene.current.value.id + }, + action: ({ session }) => { + session.unread = 0 + }, + }) + ctx.action('session.close', ({ session }) => { + // HACK: fake implementation. + // In fact, im just initializes sessions from the past 12 weeks. + + Scene.close(session.id!) + }) +} diff --git a/packages/im/client/app/navigation/list.vue b/packages/im/client/app/navigation/list.vue new file mode 100644 index 0000000..ce23245 --- /dev/null +++ b/packages/im/client/app/navigation/list.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/packages/im/client/app/navigation/session-item.vue b/packages/im/client/app/navigation/session-item.vue new file mode 100644 index 0000000..b6cc2e1 --- /dev/null +++ b/packages/im/client/app/navigation/session-item.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/packages/im/client/app/scene/create-guild.vue b/packages/im/client/app/scene/create-guild.vue new file mode 100644 index 0000000..8790ff2 --- /dev/null +++ b/packages/im/client/app/scene/create-guild.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/im/client/app/scene/empty.vue b/packages/im/client/app/scene/empty.vue new file mode 100644 index 0000000..d71f88c --- /dev/null +++ b/packages/im/client/app/scene/empty.vue @@ -0,0 +1,3 @@ + diff --git a/packages/im/client/app/scene/index.ts b/packages/im/client/app/scene/index.ts new file mode 100644 index 0000000..754f781 --- /dev/null +++ b/packages/im/client/app/scene/index.ts @@ -0,0 +1,202 @@ +import { computed, Component, ref, Ref, MaybeRef } from 'vue' +import { Context, Dict, Service, send } from '@cordisjs/client' +import type { Im } from '@satorijs/plugin-im' + +import Empty from './empty.vue' +import Loading from './loading.vue' + +import CreateGuildScene from './create-guild.vue' +import MsgBoxScene from './msgbox.vue' + +interface Lifecycle { + onInit?: (scene: Scene[K]) => Promise + onMount?: (scene: Scene[K]) => Promise + onUnmount?: (scene: Scene[K]) => Promise +} + +type SceneDict = Dict< + { + component: Component + aside?: Component + handlers?: Lifecycle + }, + K +> + +let startId: number = 0 +const registered = {} as SceneDict + +export const Native = { + MsgBoxScene, + CreateGuildScene, +} + +// TODO: better type constraints. +export interface Scenes { + 'edit-user': { user?: Im.User } + 'create-guild': void + 'msg-box': { msgs?: Array } + search: void +} + +type Scene = { + [K in keyof Scenes]: Scene.Prototype & + (Scenes[K] extends object ? Scenes[K] : {}) & { + readonly lifecycles?: Lifecycle + readonly id: string + readonly type: keyof Scenes + readonly uid?: string + } +} + +namespace Scene { + type ToCreate = Omit + + export type ID = string + + export interface Prototype { + lifecycles?: Lifecycle + _data?: any + avatar?: string + brief?: MaybeRef + id?: ID + loaded?: boolean + pinned?: MaybeRef + subtitle?: string + title: MaybeRef + type?: keyof Scenes + uid?: string + unread?: MaybeRef + } + + export const current = computed(() => { + const topInstance = top() + if (!topInstance) { + return { ...({} as Prototype), component: Empty, aside: undefined } + } + if (!(topInstance as any).loaded) { + return { ...topInstance, component: Loading, aside: undefined } + } + return { + ...topInstance, + component: registered[topInstance.type!].component, + aside: registered[topInstance.type!].aside, + } + }) + export const history = ref>([]) + export const mounted = ref>({}) + + export function init() { + history.value = [] + mounted.value = {} + } + + export async function dispose() { + const topInstance = top() + const dispose = topInstance?.lifecycles?.onUnmount + dispose + ? dispose(topInstance).then(() => { + topInstance.loaded = false + }) + : Promise.resolve() + } + + export function register( + type: K, + component: Component, + aside?: Component, + handlers?: Lifecycle + ) { + registered[type] = { component, aside, handlers } + } + + export async function create( + type: K, + data: ToCreate, + handlers?: Lifecycle + ) { + const scene: Prototype = data + scene.type = type + scene.lifecycles = handlers || registered[scene.type]?.handlers || {} + + const uid = `#${scene.type}-${scene.uid}` + if (scene.uid && mounted.value[uid]) { + return uid + } + + const id = scene.uid ? uid : genWindowId() + scene.id = id + scene.type = type + scene.loaded = true + scene.pinned = false + + mounted.value[id] = scene + + if (scene.lifecycles?.onInit) { + scene.loaded = false + await scene.lifecycles.onInit(scene) + mounted.value[id].loaded = true + } + + return id + } + + export function rend(id: ID) { + const index = history.value.findIndex((value) => value === id) + const instance = mounted.value[id] + const topInstance = top() + + if (!instance || (index === history.value.length - 1 && history.value.length)) { + return + } + + const processUnmount = topInstance?.lifecycles?.onUnmount + ? ((topInstance.loaded = false), + topInstance?.lifecycles.onUnmount(topInstance).then(() => { + topInstance.loaded = true + })) + : Promise.resolve() + + if (index > 0) { + history.value.splice(index, 1) + } + + history.value.push(id) + + instance.loaded = false + instance.lifecycles?.onMount + ? processUnmount.then(() => { + return instance.lifecycles?.onMount!(instance).then(() => { + instance.loaded = true + }) + }) + : processUnmount.then(() => { + instance.loaded = true + }) + } + + export function close(id: ID) { + const index = history.value.findIndex((value) => value === id) + if (index !== -1) { + history.value.splice(index, 1) + } + delete mounted.value[id] + } + + export function pop() { + const id = history.value.pop()! + delete mounted.value[id] + } + + function top(): Prototype | undefined { + const top = mounted.value[history.value[history.value.length - 1]] + return top || undefined + } + + function genWindowId(): ID { + startId += 1 // HACK + return startId.toString() + } +} + +export default Scene diff --git a/packages/im/client/app/scene/loading.vue b/packages/im/client/app/scene/loading.vue new file mode 100644 index 0000000..26c846e --- /dev/null +++ b/packages/im/client/app/scene/loading.vue @@ -0,0 +1,3 @@ + diff --git a/packages/im/client/app/scene/msgbox.vue b/packages/im/client/app/scene/msgbox.vue new file mode 100644 index 0000000..25dd57d --- /dev/null +++ b/packages/im/client/app/scene/msgbox.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/im/client/app/user/index.ts b/packages/im/client/app/user/index.ts new file mode 100644 index 0000000..2806e5b --- /dev/null +++ b/packages/im/client/app/user/index.ts @@ -0,0 +1,21 @@ +import { Context } from '@cordisjs/client' +import type { Im } from '@satorijs/plugin-im' +import Login from './login-form.vue' + +export { default as MyInfoScene } from './my-info.vue' +export { default as GlobalSearchScene } from './search.vue' + +export function getDisplayName(user: Im.User, friend?: { name: string }, member?: Im.Member) { + let expr = user.nick || user.name + if (member) { + expr = member.nick || member.name || expr + } + if (friend) { + expr = friend.name || expr + } + return expr! +} + +export default function (ctx: Context) { + ctx.app.component('im-login', Login) +} diff --git a/packages/im/client/app/user/login-form.vue b/packages/im/client/app/user/login-form.vue new file mode 100644 index 0000000..3b4350c --- /dev/null +++ b/packages/im/client/app/user/login-form.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/packages/im/client/app/user/my-info.vue b/packages/im/client/app/user/my-info.vue new file mode 100644 index 0000000..dd2a574 --- /dev/null +++ b/packages/im/client/app/user/my-info.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/im/client/app/user/search.vue b/packages/im/client/app/user/search.vue new file mode 100644 index 0000000..3f77bb9 --- /dev/null +++ b/packages/im/client/app/user/search.vue @@ -0,0 +1,63 @@ + + + diff --git a/packages/im/client/components/bot/list.vue b/packages/im/client/components/bot/list.vue new file mode 100644 index 0000000..0764089 --- /dev/null +++ b/packages/im/client/components/bot/list.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/packages/im/client/components/button.vue b/packages/im/client/components/button.vue new file mode 100644 index 0000000..ad1f27c --- /dev/null +++ b/packages/im/client/components/button.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/im/client/components/divider.vue b/packages/im/client/components/divider.vue new file mode 100644 index 0000000..323256c --- /dev/null +++ b/packages/im/client/components/divider.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/im/client/components/editor/emoji.vue b/packages/im/client/components/editor/emoji.vue new file mode 100644 index 0000000..2aa0e04 --- /dev/null +++ b/packages/im/client/components/editor/emoji.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/im/client/components/editor/index.ts b/packages/im/client/components/editor/index.ts new file mode 100644 index 0000000..87bf45f --- /dev/null +++ b/packages/im/client/components/editor/index.ts @@ -0,0 +1 @@ +export { default as EmojiPanel } from './emoji.vue' diff --git a/packages/im/client/components/essential.vue b/packages/im/client/components/essential.vue new file mode 100644 index 0000000..3345960 --- /dev/null +++ b/packages/im/client/components/essential.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/im/client/components/form/form.vue b/packages/im/client/components/form/form.vue new file mode 100644 index 0000000..2d5e258 --- /dev/null +++ b/packages/im/client/components/form/form.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/im/client/components/form/item.vue b/packages/im/client/components/form/item.vue new file mode 100644 index 0000000..8027865 --- /dev/null +++ b/packages/im/client/components/form/item.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/im/client/components/fs/file.vue b/packages/im/client/components/fs/file.vue new file mode 100644 index 0000000..9bbacbe --- /dev/null +++ b/packages/im/client/components/fs/file.vue @@ -0,0 +1,67 @@ + + + diff --git a/packages/im/client/components/fs/index.ts b/packages/im/client/components/fs/index.ts new file mode 100644 index 0000000..1c5c97c --- /dev/null +++ b/packages/im/client/components/fs/index.ts @@ -0,0 +1 @@ +export { default as FilePicker } from './file.vue' diff --git a/packages/im/client/components/grouper/default.vue b/packages/im/client/components/grouper/default.vue new file mode 100644 index 0000000..cef9994 --- /dev/null +++ b/packages/im/client/components/grouper/default.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/im/client/components/grouper/index.ts b/packages/im/client/components/grouper/index.ts new file mode 100644 index 0000000..99091a5 --- /dev/null +++ b/packages/im/client/components/grouper/index.ts @@ -0,0 +1,52 @@ +export interface Resolver { + readonly name: string + readonly filter?: (self: Resolver, data: T) => boolean + readonly options?: { + unique?: boolean + byName?: boolean + } +} + +export interface Group { + name: string + data: T[] +} + +export function filterByGroupAttr(self: Resolver, data: T) { + return data.group === self.name +} + +class Grouper { + constructor(private _data: T[], private _groupers: Resolver[]) {} + + createGroup(...groups: Resolver[]) { + this._groupers.push(...groups) + } + + resolve(): Group[] { + const result = [] as Group[] + let data = [...this._data] + + for (let i = 0; i < this._groupers.length; i++) { + const grouper = this._groupers[i] + let grouped: Group = { + name: this._groupers[i].name, + data: [], + } + + for (let j = 0; j < this._data.length; j++) { + if (grouper.filter && grouper.filter(grouper, data[j])) { + grouped.data.push(data[j]) + grouper.options?.unique && data.slice(j, 1) + } + } + + if (grouped.data.length > 0 && grouped.name) { + result.push(grouped) + } + } + return result + } +} + +export default Grouper diff --git a/packages/im/client/components/index.ts b/packages/im/client/components/index.ts new file mode 100644 index 0000000..9757c4d --- /dev/null +++ b/packages/im/client/components/index.ts @@ -0,0 +1,34 @@ +import { Context } from '@cordisjs/client' +import role from './role' +import stepper from './stepper' +import tab from './tab' +import Bot from './bot/list.vue' +import Divider from './divider.vue' +import Essential from './essential.vue' +import { EmojiPanel } from './editor' +import { FilePicker } from './fs' +import Form from './form/form.vue' +import FormItem from './form/item.vue' +import More from './more.vue' +import Search from './search/global.vue' + +export * from './fs' +export * from './stepper' +export * from './tab' +export * from './role' + +export default function apply(ctx: Context) { + ctx.app.component('file-picker', FilePicker) + ctx.app.component('essential', Essential) + ctx.app.component('more', More) + ctx.app.component('im-search', Search) + ctx.app.component('im-divider', Divider) + ctx.app.component('im-form', Form) + ctx.app.component('im-form-item', FormItem) + ctx.app.component('emoji-panel', EmojiPanel) + ctx.app.component('bot-commands', Bot) + + ctx.app.use(tab) + ctx.app.use(stepper) + ctx.plugin(role) +} diff --git a/packages/im/client/components/more.vue b/packages/im/client/components/more.vue new file mode 100644 index 0000000..f8119b8 --- /dev/null +++ b/packages/im/client/components/more.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/im/client/components/role/avatar.vue b/packages/im/client/components/role/avatar.vue new file mode 100644 index 0000000..69ad3dd --- /dev/null +++ b/packages/im/client/components/role/avatar.vue @@ -0,0 +1,104 @@ + + + + + + diff --git a/packages/im/client/components/role/index.ts b/packages/im/client/components/role/index.ts new file mode 100644 index 0000000..888dea6 --- /dev/null +++ b/packages/im/client/components/role/index.ts @@ -0,0 +1,12 @@ +import { Context } from '@cordisjs/client' +import Avatar from './avatar.vue' +import Tag from './role-tag.vue' +import UserSelector from './selector.vue' +import TinyUserSelector from './selector-tiny.vue' + +export default function (ctx: Context) { + ctx.app.component('im-avatar', Avatar) + ctx.app.component('im-tag', Tag) + ctx.app.component('user-selector', UserSelector) + ctx.app.component('tiny-selector', TinyUserSelector) +} diff --git a/packages/im/client/components/role/role-tag.vue b/packages/im/client/components/role/role-tag.vue new file mode 100644 index 0000000..09a7d2a --- /dev/null +++ b/packages/im/client/components/role/role-tag.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/im/client/components/role/selector-tiny.vue b/packages/im/client/components/role/selector-tiny.vue new file mode 100644 index 0000000..bd260b4 --- /dev/null +++ b/packages/im/client/components/role/selector-tiny.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/im/client/components/role/selector.vue b/packages/im/client/components/role/selector.vue new file mode 100644 index 0000000..955a51b --- /dev/null +++ b/packages/im/client/components/role/selector.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/packages/im/client/components/role/user-card.vue b/packages/im/client/components/role/user-card.vue new file mode 100644 index 0000000..cc340bc --- /dev/null +++ b/packages/im/client/components/role/user-card.vue @@ -0,0 +1 @@ + diff --git a/packages/im/client/components/search/global.vue b/packages/im/client/components/search/global.vue new file mode 100644 index 0000000..9842a45 --- /dev/null +++ b/packages/im/client/components/search/global.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/packages/im/client/components/stepper/index.ts b/packages/im/client/components/stepper/index.ts new file mode 100644 index 0000000..464dd5e --- /dev/null +++ b/packages/im/client/components/stepper/index.ts @@ -0,0 +1,10 @@ +import { App } from 'vue' +import Stepper from './stepper.vue' +import Step from './step.vue' + +export { Stepper } from './step' + +export default function (app: App) { + app.component('im-stepper', Stepper) + app.component('step-item', Step) +} diff --git a/packages/im/client/components/stepper/step.ts b/packages/im/client/components/stepper/step.ts new file mode 100644 index 0000000..38b2c81 --- /dev/null +++ b/packages/im/client/components/stepper/step.ts @@ -0,0 +1,49 @@ +export type StepCallback = (data: any) => any + +export namespace Step { + export type Status = 'inactive' | 'active' | 'completed' +} + +export class Stepper { + data: any = {} + steps: Array = [] + index: number = 0 + + addStep(callback: (data: any) => any) { + this.steps.push(callback) + } + + submit() { + this.steps[this.steps.length - 1](this.data) + } + + next() { + if (this.index < this.steps.length - 1) { + if (this.index) { + this.data = this._execute() + } + this.index++ + } + } + + prev() { + if (this.index > 0) { + this.index-- + } + } + + hasNext() { + return this.index < this.steps.length - 1 + } + + hasPrev() { + return this.index > 0 + } + + private _execute() { + if (this.steps[this.index]) { + return this.steps[this.index](this.data) + } + return this.data + } +} diff --git a/packages/im/client/components/stepper/step.vue b/packages/im/client/components/stepper/step.vue new file mode 100644 index 0000000..4444f18 --- /dev/null +++ b/packages/im/client/components/stepper/step.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/im/client/components/stepper/stepper.vue b/packages/im/client/components/stepper/stepper.vue new file mode 100644 index 0000000..1104cd1 --- /dev/null +++ b/packages/im/client/components/stepper/stepper.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/im/client/components/tab/index.ts b/packages/im/client/components/tab/index.ts new file mode 100644 index 0000000..e1184f7 --- /dev/null +++ b/packages/im/client/components/tab/index.ts @@ -0,0 +1,6 @@ +import { App } from 'vue' +import Tab from './tab.vue' + +export default function (app: App) { + app.component('im-tab', Tab) +} diff --git a/packages/im/client/components/tab/tab.vue b/packages/im/client/components/tab/tab.vue new file mode 100644 index 0000000..473a751 --- /dev/null +++ b/packages/im/client/components/tab/tab.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/im/client/icons/add-square.vue b/packages/im/client/icons/add-square.vue new file mode 100644 index 0000000..15aa972 --- /dev/null +++ b/packages/im/client/icons/add-square.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/ban.vue b/packages/im/client/icons/ban.vue new file mode 100644 index 0000000..b5068ae --- /dev/null +++ b/packages/im/client/icons/ban.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/bell.vue b/packages/im/client/icons/bell.vue new file mode 100644 index 0000000..52b6f97 --- /dev/null +++ b/packages/im/client/icons/bell.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/bookmark.vue b/packages/im/client/icons/bookmark.vue new file mode 100644 index 0000000..d4bcef8 --- /dev/null +++ b/packages/im/client/icons/bookmark.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/channel/text.vue b/packages/im/client/icons/channel/text.vue new file mode 100644 index 0000000..33e8ad4 --- /dev/null +++ b/packages/im/client/icons/channel/text.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/chat.vue b/packages/im/client/icons/chat.vue new file mode 100644 index 0000000..52ed281 --- /dev/null +++ b/packages/im/client/icons/chat.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/check.vue b/packages/im/client/icons/check.vue new file mode 100644 index 0000000..fc3ed90 --- /dev/null +++ b/packages/im/client/icons/check.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/circle-check.vue b/packages/im/client/icons/circle-check.vue new file mode 100644 index 0000000..fefcbb3 --- /dev/null +++ b/packages/im/client/icons/circle-check.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/close.vue b/packages/im/client/icons/close.vue new file mode 100644 index 0000000..2f4a170 --- /dev/null +++ b/packages/im/client/icons/close.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/copy.vue b/packages/im/client/icons/copy.vue new file mode 100644 index 0000000..87e5194 --- /dev/null +++ b/packages/im/client/icons/copy.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/edit.vue b/packages/im/client/icons/edit.vue new file mode 100644 index 0000000..ae6e957 --- /dev/null +++ b/packages/im/client/icons/edit.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/editor/bold.vue b/packages/im/client/icons/editor/bold.vue new file mode 100644 index 0000000..ebc1790 --- /dev/null +++ b/packages/im/client/icons/editor/bold.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/editor/italic.vue b/packages/im/client/icons/editor/italic.vue new file mode 100644 index 0000000..9898465 --- /dev/null +++ b/packages/im/client/icons/editor/italic.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/ellipsis-v.vue b/packages/im/client/icons/ellipsis-v.vue new file mode 100644 index 0000000..7055f43 --- /dev/null +++ b/packages/im/client/icons/ellipsis-v.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/emoji/blank.vue b/packages/im/client/icons/emoji/blank.vue new file mode 100644 index 0000000..f96fa59 --- /dev/null +++ b/packages/im/client/icons/emoji/blank.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/emoji/smile-wink.vue b/packages/im/client/icons/emoji/smile-wink.vue new file mode 100644 index 0000000..55889e2 --- /dev/null +++ b/packages/im/client/icons/emoji/smile-wink.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/envelope.vue b/packages/im/client/icons/envelope.vue new file mode 100644 index 0000000..8dae5bb --- /dev/null +++ b/packages/im/client/icons/envelope.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/folder.vue b/packages/im/client/icons/folder.vue new file mode 100644 index 0000000..d3c682a --- /dev/null +++ b/packages/im/client/icons/folder.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/group.vue b/packages/im/client/icons/group.vue new file mode 100644 index 0000000..166da94 --- /dev/null +++ b/packages/im/client/icons/group.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/image.vue b/packages/im/client/icons/image.vue new file mode 100644 index 0000000..4a94bb5 --- /dev/null +++ b/packages/im/client/icons/image.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/inbox.vue b/packages/im/client/icons/inbox.vue new file mode 100644 index 0000000..d520bc6 --- /dev/null +++ b/packages/im/client/icons/inbox.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/index.ts b/packages/im/client/icons/index.ts new file mode 100644 index 0000000..a79cd07 --- /dev/null +++ b/packages/im/client/icons/index.ts @@ -0,0 +1,74 @@ +import { icons, IconSquareCheck } from '@cordisjs/client' +import AddSquare from './add-square.vue' +import Ban from './ban.vue' +import Bell from './bell.vue' +import Bookmark from './bookmark.vue' +import Blank from './emoji/blank.vue' +import Check from './check.vue' +import Close from './close.vue' +import CircleCheck from './circle-check.vue' +import Copy from './copy.vue' +import Edit from './edit.vue' +import EllipsisV from './ellipsis-v.vue' +import Envelope from './envelope.vue' +import Folder from './folder.vue' +import Group from './group.vue' +import Image from './image.vue' +import Inbox from './inbox.vue' +import Chat from './chat.vue' +import Message from './message.vue' +import Minus from './minus.vue' +import SmileWink from './emoji/smile-wink.vue' +import Pin from './pin.vue' +import Quote from './quote.vue' +import Recall from './recall.vue' +import Refresh from './refresh.vue' +import SettingsGear from './settings-gear.vue' +import SettingsSlider from './settings-slider.vue' +import Settings from './settings.vue' +import Share from './share.vue' +import TrashCan from './trash-can.vue' + +import TextChannel from './channel/text.vue' + +import Bold from './editor/bold.vue' +import Italic from './editor/italic.vue' + +icons.register('im:add-square', AddSquare) +icons.register('im:ban', Ban) +icons.register('im:bell', Bell) +icons.register('im:bookmark', Bookmark) +icons.register('im:chat', Chat) +icons.register('im:check', Check) +icons.register('im:check-round', CircleCheck) +icons.register('im:close', Close) +icons.register('im:copy', Copy) +icons.register('im:edit', Edit) +icons.register('im:ellipsis-vertical', EllipsisV) +icons.register('im:envelope', Envelope) +icons.register('im:folder', Folder) +icons.register('im:group', Group) +icons.register('im:image', Image) +icons.register('im:inbox', Inbox) +icons.register('im:message', Message) +icons.register('im:minus', Minus) +icons.register('im:pin', Pin) +icons.register('im:quote', Quote) +icons.register('im:readed', IconSquareCheck) +icons.register('im:recall', Recall) +icons.register('im:refresh', Refresh) +icons.register('im:settings', Settings) +icons.register('im:settings-gear', SettingsGear) +icons.register('im:settings-slider', SettingsSlider) +icons.register('im:share', Share) +icons.register('im:trash-can', TrashCan) + +icons.register('im:text-channel', TextChannel) + +// emoji +icons.register('im:emoji', SmileWink) +icons.register('im:personnel', Blank) + +// editor +icons.register('editor:bold', Bold) +icons.register('editor:italic', Italic) diff --git a/packages/im/client/icons/message.vue b/packages/im/client/icons/message.vue new file mode 100644 index 0000000..33e8ad4 --- /dev/null +++ b/packages/im/client/icons/message.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/minus.vue b/packages/im/client/icons/minus.vue new file mode 100644 index 0000000..cedfc6e --- /dev/null +++ b/packages/im/client/icons/minus.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/pin.vue b/packages/im/client/icons/pin.vue new file mode 100644 index 0000000..23110f7 --- /dev/null +++ b/packages/im/client/icons/pin.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/quote.vue b/packages/im/client/icons/quote.vue new file mode 100644 index 0000000..cb153c5 --- /dev/null +++ b/packages/im/client/icons/quote.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/recall.vue b/packages/im/client/icons/recall.vue new file mode 100644 index 0000000..8427256 --- /dev/null +++ b/packages/im/client/icons/recall.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/refresh.vue b/packages/im/client/icons/refresh.vue new file mode 100644 index 0000000..f0b7bef --- /dev/null +++ b/packages/im/client/icons/refresh.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/settings-gear.vue b/packages/im/client/icons/settings-gear.vue new file mode 100644 index 0000000..0ae84cc --- /dev/null +++ b/packages/im/client/icons/settings-gear.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/settings-slider.vue b/packages/im/client/icons/settings-slider.vue new file mode 100644 index 0000000..79d13e6 --- /dev/null +++ b/packages/im/client/icons/settings-slider.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/settings.vue b/packages/im/client/icons/settings.vue new file mode 100644 index 0000000..34ab0d4 --- /dev/null +++ b/packages/im/client/icons/settings.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/share.vue b/packages/im/client/icons/share.vue new file mode 100644 index 0000000..bfff7a9 --- /dev/null +++ b/packages/im/client/icons/share.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/icons/trash-can.vue b/packages/im/client/icons/trash-can.vue new file mode 100644 index 0000000..eee251b --- /dev/null +++ b/packages/im/client/icons/trash-can.vue @@ -0,0 +1,9 @@ + diff --git a/packages/im/client/index.ts b/packages/im/client/index.ts index 190facb..ad72758 100644 --- a/packages/im/client/index.ts +++ b/packages/im/client/index.ts @@ -1,5 +1,312 @@ -import { Context } from '@cordisjs/client' -import {} from '../src' +import { computed, reactive, ref, watch } from 'vue' +import { Context, send, Service } from '@cordisjs/client' +import {} from '@cordisjs/loader' +import { Dict } from '@satorijs/core' +import {} from '@satorijs/plugin-im-webui' +import type { Im } from '@satorijs/plugin-im' +import shared from './shared' +import components from './components' +import webui, { Scene, ImApp } from './app' +import { getDisplayName } from './app/user' -export default (ctx: Context) => { +import 'virtual:uno.css' + +export { default as shared } from './shared' +export * from './icons' +export * from '@satorijs/plugin-im-utils' + +declare module '@cordisjs/client' { + interface Context { + 'im.client': ChatService + } +} + +interface Cache { + users: Dict + members: Dict> + tasks: Dict> +} + +interface LoggedUser { + login: Im.Login + friends: Array + guilds: Array + notifications: Array + messages: Dict<{ + data: Array + unread: number + }> +} + +const localToken = shared.value.token +let chat = reactive({ + users: {}, + members: {}, + tasks: {}, + messages: {}, +} as any) + +export default class ChatService extends Service { + static inject = { optional: ['im.client'] } + + status = ref<'verifying' | 'logging-in' | 'syncing' | 'logged'>('verifying') + + login = computed(() => chat.login) + + guilds = computed(() => chat.guilds) + + friends = computed(() => chat.friends) + + get _cache() { + return chat + } + + constructor(public ctx: Context) { + super(ctx, 'im.client', true) + ctx.plugin(components) + ctx.plugin(webui) + + if (localToken) { + send('im/v1/login-add', { login: { token: localToken } as Im.Login }) + .then((data) => { + this.setLogin(data) + }) + .catch(() => { + shared.value.token = '' + this.status.value = 'logging-in' + }) + } else { + this.status.value = 'logging-in' + } + + ctx.addEventListener('beforeunload', async () => { + await Scene.dispose() + }) + + ctx.page({ + name: 'IM', + path: '/im', + icon: 'activity:chat', + component: ImApp, + }) + } + + // TODO: promisified. + async setLogin(login: Im.Login) { + chat.login = null as any + chat.friends = null as any + chat.guilds = null as any + chat.notifications = null as any + chat.messages = {} as any + + chat.login = login + chat.login.selfId = login.user!.id + chat.users[login.selfId!] = login.user! + shared.value.token = login.token + + this.status.value = 'syncing' + + try { + Scene.init() // trans to Service? + + chat.guilds = await send('im/v1/guild/fetch-all', { login: chat.login }) + chat.friends = await send('im/v1/friend/fetch-all', { login: chat.login }) + + const channels = await send('im/v1/channel/recent', { login: chat.login }) + + for (const channel of channels) { + // TODO: Dont import value like Im.Channel.Type + if (channel.type !== 1) { + const guild = chat.guilds.find((value) => value.id === channel.guild?.id)! + await Scene.create('chat-guild', { + uid: channel.id, + title: guild.name!, + subtitle: channel.name, + channel: channel, + guild, + }) + continue + } + const friend = chat.friends.find((value) => value.id === channel.friend?.id)! + await Scene.create('chat-friend', { + uid: channel.id, + title: getDisplayName(friend.user, { name: friend.nick! }), + subtitle: channel!.name, + friend, + channel, + }) + } + + for (const friend of chat.friends) { + chat.users[friend.user.id] = friend.user + } + } catch (err) { + this.status.value = 'logging-in' + return + } + + this.status.value = 'logged' + } + + getLogin() { + return chat.login + } + + logout() { + chat.login = undefined! + shared.value.token = '' + + this.status.value = 'logging-in' + } + + async getUser(uid: string) { + if (!chat.users[uid] && !chat.tasks[uid]) { + chat.users[uid] = await send('im/v1/user/fetch', { login: chat.login, uid }) + delete chat.tasks[uid] + } + return chat.users[uid] + } + + async getMember(gid: string, uid: string) { + const key = `${gid}-${uid}` + if (!chat.members[gid]) chat.members[gid] = {} + if (!chat.members[gid][uid] && !chat.tasks[key]) { + chat.members[gid][uid] = await send('im/v1/guild-member/fetch', { + login: chat.login, + gid, + uid, + }) + delete chat.tasks[key] + } + return chat.members[gid][uid] + } + + async _getAllMembers(gid: string) { + const result = await send('im/v1/guild-member/fetch-all', { + login: chat.login, + gid, + }) + + for (let i = 0; i < result.length; i++) { + chat.members[gid][result[i].user.id] = result[i] + } + + return Object.values(chat.members[gid]) + } + + mountMessage(message: Im.Message) { + chat.messages[message.channel!.id].data.push(message) + } + + async getMessageData(channel: Im.Channel) { + const cid = channel.id + const messages = chat.messages[cid] + + if (!messages) { + const _ = undefined + chat.messages[cid] = { + data: [], + unread: await send('im/v1/message/unread-count', { login: chat.login, cid }), + } + await this.updateMessageList(channel, _, _, 30) + } + + return chat.messages[cid] + } + + async updateMessageList( + channel: Im.Channel, + dir: 'before' | 'after' = 'before', + endpoint: number = new Date().getTime(), + limit: number = 10 + ) { + const cid = channel.id + const data = await send('im/v1/message/fetch', { + login: chat.login, + cid, + endpoint, + dir, + limit, + }) + + for (const message of data) { + const user = await this.getUser(message.user!.id) + message.user = user + if (channel.type !== 1) { + const member = await this.getMember(channel.guild!.id, message.user!.id) + message.member = member + } + } + + dir === 'before' + ? chat.messages[cid].data.unshift(...data.reverse()) + : chat.messages[cid].data.push(...data) + } + + _eventHandler = async (event: Im.Event) => { + this.ctx.logger('im.client').info(`[event] type: ${event.type}`) + + const item = event.type.split('-')[0] + const ptr = chat[`${item}s`] + + switch (true) { + case 'message' === event.type: { + const response = event.message! + const cid = response.channel!.id + + if (!chat.messages[cid]) { + if (response.member) { + const guild = this.guilds.value.find((value) => value.id === response.member!.guild.id)! + const channel = guild?.channels?.find((value) => value.id === cid)! + await Scene.create('chat-guild', { + uid: cid, + title: guild?.name!, + subtitle: channel!.name, + guild, + channel, + }) + } else { + const friend = this.friends.value.find((value) => value.channel?.id === cid)! + const channel = friend.channel! + await Scene.create('chat-friend', { + uid: cid, + title: getDisplayName(friend.user, { name: friend.nick! }), + subtitle: channel!.name, + friend, + channel, + }) + } + } + + if (response.user!.id === chat.login.selfId) { + chat.messages[cid].data.some( + (value, i) => value.sid === response.sid && chat.messages[cid].data.splice(i, 1) + ) + } else { + chat.messages[cid].unread += 1 + } + + const { sid: _, ...message } = response + if (chat.messages[message.channel!.id]) { + chat.messages[message.channel!.id].data.push(message) + } + break + } + case /-added$/.test(event.type): { + ptr.push(event[item]) + break + } + case /-deleted$/.test(event.type): { + ptr.filter((value: any) => value.id !== event[item]) + break + } + case /-updated$/.test(event.type): { + const existingItem = ptr.find((value: any) => value.id === event[item]) + existingItem ? Object.assign(existingItem, event[item]) : ptr.push(event[item]) + break + } + default: + console.error(`cannot resolve event: ${event}`) + } + } } diff --git a/packages/im/client/shared/index.ts b/packages/im/client/shared/index.ts new file mode 100644 index 0000000..3b089bd --- /dev/null +++ b/packages/im/client/shared/index.ts @@ -0,0 +1,12 @@ +import { Dict, useStorage } from '@cordisjs/client' +import type Window from '../components/scene' + +interface SharedConfig { + token: string +} + +const shared = useStorage('im', 1, () => ({ + token: '', +})) + +export default shared diff --git a/packages/im/client/utils/index.ts b/packages/im/client/utils/index.ts new file mode 100644 index 0000000..b473dce --- /dev/null +++ b/packages/im/client/utils/index.ts @@ -0,0 +1,46 @@ +export * from './time' + +export function lockdown any>( + fn: T +): (...args: Parameters) => void { + let lock: boolean + + return async function (...args: Parameters) { + if (!lock) { + lock = true + try { + await fn.apply(this, args) + } finally { + lock = false + } + } + } +} + +export function throttle any>( + fn: T, + duration: number = 1000 +): (...args: Parameters) => void { + let inThrottle: boolean + + return function (...args: Parameters) { + if (!inThrottle) { + inThrottle = true + fn.apply(this, args) + setTimeout(() => (inThrottle = false), duration) + } + } +} + +export function debounce any>( + fn: T, + duration: number = 1000 +): (...args: Parameters) => void { + let timeout: ReturnType | null = null + + return function (...args: Parameters) { + clearTimeout(timeout!) + + timeout = setTimeout(() => fn.apply(this, args), duration) + } +} diff --git a/packages/im/client/utils/time.ts b/packages/im/client/utils/time.ts new file mode 100644 index 0000000..68246e4 --- /dev/null +++ b/packages/im/client/utils/time.ts @@ -0,0 +1,38 @@ +export function formatTimestamp(ts: number, format: string = 'YMDhms'): string { + const date = new Date(ts) + + const options: Intl.DateTimeFormatOptions = { + year: format.includes('Y') ? 'numeric' : undefined, + month: format.includes('M') ? 'long' : undefined, + day: format.includes('D') ? 'numeric' : undefined, + hour: format.includes('h') ? '2-digit' : undefined, + minute: format.includes('m') ? '2-digit' : undefined, + second: format.includes('s') ? '2-digit' : undefined, + } + + const formatter = new Intl.DateTimeFormat('zh-CN', options) + return formatter.format(date) +} + +export function formatTimestampBySpan(ts: number): string { + const now = new Date() + const date = new Date(ts) + + const isSameDay = now.toDateString() === date.toDateString() + const isSameMonth = now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() + const isSameYear = now.getFullYear() === date.getFullYear() + + let format: string = 'ymdhms' + + if (isSameDay) { + format = 'hm' + } else if (isSameMonth) { + format = 'Dhm' + } else if (isSameYear) { + format = 'MDhm' + } else { + format = 'YMDhm' + } + + return formatTimestamp(ts, format) +} diff --git a/packages/im/package.json b/packages/im/package.json index 910f811..a726a85 100644 --- a/packages/im/package.json +++ b/packages/im/package.json @@ -1,5 +1,5 @@ { - "name": "@satorijs/plugin-im", + "name": "@satorijs/plugin-im-webui", "description": "IM for Satori", "version": "0.0.0", "type": "module", @@ -29,22 +29,25 @@ "im" ], "cordis": { - "service": { - "required": [ - "webui" - ] - } + "service": {} }, "peerDependencies": { - "@cordisjs/plugin-webui": "^0.1.7", - "@satorijs/core": "^4.1.1" + "@cordisjs/plugin-webui": "^0.1.12", + "@satorijs/core": "^4.1.1", + "@satorijs/plugin-im": "^0.0.0", + "@satorijs/plugin-im-utils": "^0.0.0" }, "devDependencies": { - "@cordisjs/client": "^0.1.7", - "@cordisjs/plugin-webui": "^0.1.7", - "@satorijs/core": "^4.1.1" + "@cordisjs/client": "^0.1.12", + "@cordisjs/plugin-http": "^0.5.3", + "@cordisjs/plugin-webui": "^0.1.12", + "@satorijs/core": "^4.1.1", + "@satorijs/plugin-im": "^0.0.0", + "@satorijs/plugin-server": "^2.6.5" }, "dependencies": { + "@satorijs/plugin-im-utils": "^0.0.0", + "cordis": "^3.17.7", "cosmokit": "^1.6.2" } } diff --git a/packages/im/src/entry.ts b/packages/im/src/entry.ts new file mode 100644 index 0000000..b0b88ef --- /dev/null +++ b/packages/im/src/entry.ts @@ -0,0 +1,77 @@ +import { Context, Service } from '@satorijs/core' +import { Client, Entry, Events } from '@cordisjs/plugin-webui' + +export class ImEntry extends Entry { + constructor( + public ctx: Context, + public files: Entry.Files, + public data: (client: Client) => any + ) { + super(ctx, files, data) + } + + unicast(clientId: string, data: any) { + const client = this.ctx.webui.clients[clientId] + if (client) { + const payload = { + type: 'entry:update', + body: { id: this.id, data: { ...this.data?.(client), ...data } }, + } + client.socket.send(JSON.stringify(payload)) + } + } + + unirefresh(clientId: string) { + const client = this.ctx.webui.clients[clientId] + if (client) { + const payload = { type: 'entry:refresh', body: { id: client.id, data: this.data?.(client) } } + client.socket.send(JSON.stringify(payload)) + } + } + + unipatch(clientId: string, data: any, key?: string) { + const client = this.ctx.webui.clients[clientId] + if (client) { + const payload = { type: 'entry:patch', body: { id: this.id, data, key } } + client.socket.send(JSON.stringify(payload)) + } + } +} + +export class EntryService extends Service { + static inject = ['webui', 'server', 'im', 'im.auth'] + public entry: ImEntry + + constructor(public ctx: Context) { + super(ctx, 'im.entry') + this.entry = new ImEntry( + ctx, + { + base: import.meta.url, + dev: '../client/index.ts', + prod: ['../dist/index.js', '../dist/style.css'], + }, + (client: Client) => ({ + serverUrl: ctx.server.selfUrl, + eventChan: {}, + }) + ) + } + + // Only supports v1 apis. + addListenerWithAuth(event: K, callback: any): void { + this.ctx.webui.addListener(event, async (data: any): Promise => { + try { + const decode = await this.ctx['im.auth']._verifyToken(data.login.token) + data.login.clientId = decode.clientId + data.login.selfId = decode.user!.id + return callback(data) + } catch (e) { + if (e) { + this.ctx['im.auth'].logout(data.token) + throw e + } + } + }) + } +} diff --git a/packages/im/src/index.ts b/packages/im/src/index.ts index d9b80e4..d82cd67 100644 --- a/packages/im/src/index.ts +++ b/packages/im/src/index.ts @@ -1,23 +1,290 @@ import { Context, Schema } from '@satorijs/core' -import {} from '@cordisjs/plugin-webui' +import ImService, { Im } from '@satorijs/plugin-im' +import { EntryService } from './entry' +import { v1Wrapper } from '@satorijs/plugin-im-utils' + +export interface Data { + serverUrl: string + eventChan: Im.Event +} export const name = 'im' -export const inject = { - required: ['webui'], +declare module 'cordis' { + interface Context { + im: ImService + 'im.entry': EntryService + } +} + +declare module '@cordisjs/plugin-webui' { + interface Events { + 'im/v1/login'(data: { name: string; password: string }): Promise + 'im/v1/login-add'(data: { login: Im.Login }): Promise + 'im/v1/login-change'(data: { uid: string; status: number }): Promise + 'im/v1/login-remove'(data: { login: Im.Login }): Promise + 'im/v1/register'(data: { name: string; password: string }): Promise + 'im/v1/validate-name'(data: { name: string }): Promise + + 'im/v1/bot/fetch-commands'(data: { + login: Im.Login + id: string + }): Promise> + + 'im/v1/search/user'(data: { + login: Im.Login + keyword: string + options: Im.ChunkOptions & { except: string[] } + }): Promise> + 'im/v1/search/guild'(data: { + login: Im.Login + keyword: string + options: Im.ChunkOptions & { except: string[] } + }): Promise> + 'im/v1/search/message'(data: { + login: Im.Login + keyword: string + options: Im.ChunkOptions & { except: string[] } + }): Promise> + 'im/v1/search/friend'(data: { + login: Im.Login + keyword: string + options: Im.ChunkOptions & { except: string[] } + }): Promise> + + 'im/v1/user/fetch'(data: { login: Im.Login; uid: string }): Promise + 'im/v1/user/update'(data: { login: Im.Login; nick: string; password: string }): Promise + 'im/v1/user/remove'(data: { login: Im.Login }): Promise + + 'im/v1/friend/fetch'(data: { login: Im.Login; fid: string }): Promise + 'im/v1/friend/fetch-all'(data: { login: Im.Login }): Promise> + 'im/v1/friend/remove'(data: { login: Im.Login; fid: string }): Promise + + 'im/v1/guild/fetch'(data: { login: Im.Login; gid: string }): Promise + 'im/v1/guild/fetch-all'(data: { login: Im.Login }): Promise> + 'im/v1/guild/create'(data: { + login: Im.Login + name: string + options: { inviteUsers?: string[]; initialChannelName?: string } + }): Promise + 'im/v1/guild/update'(data: { login: Im.Login; gid: string; name: string }): Promise + 'im/v1/guild/remove'(data: { login: Im.Login; gid: string }): Promise + 'im/v1/guild-member/fetch'(data: { + login: Im.Login + gid: string + uid: string + }): Promise + 'im/v1/guild-member/fetch-all'(data: { + login: Im.Login + gid: string + }): Promise> + 'im/v1/guild-member/update'(data: { + login: Im.Login + gid: string + uid?: string + name?: string + }): Promise + 'im/v1/guild-member/kick'(data: { login: Im.Login; gid: string; uid: string }): Promise + 'im/v1/guild-role/fetch'(data: { login: Im.Login; gid: string; rid: string }): Promise + 'im/v1/guild-role/fetch-all'(data: { login: Im.Login; gid: string }): Promise> + 'im/v1/guild-role/create'(data: { + login: Im.Login + gid: string + name: string + color: number + }): Promise + 'im/v1/guild-role/update'(data: { login: Im.Login; gid: string; rid: string }): Promise + 'im/v1/guild-role/remove'(data: { login: Im.Login; gid: string; rid: string }): Promise + + 'im/v1/channel/fetch'(data: { login: Im.Login; cid: string }): Promise + 'im/v1/channel/create'(data: { + login: Im.Login + gid: string + name: string + }): Promise + 'im/v1/channel/update'(data: { login: Im.Login; cid: string; name: string }): Promise + 'im/v1/channel/remove'(data: { login: Im.Login; cid: string }): Promise + 'im/v1/channel/update-settings'(data: { login: Im.Login; cid: string }): Promise + 'im/v1/channel/recent'(data: { login: Im.Login }): Promise> + + 'im/v1/message/fetch'(data: { + login: Im.Login + cid: string + endpoint: number + dir?: 'before' | 'after' + limit?: number + }): Promise> + 'im/v1/message/create'(data: { login: Im.Login; message: Im.Message }): Promise + 'im/v1/message/update'(data: { login: Im.Login; message: Im.Message }): Promise + 'im/v1/message/recall'(data: { login: Im.Login; cid: string; mid: string }): Promise + 'im/v1/message/unread-count'(data: { login: Im.Login; cid: string }): Promise + + 'im/v1/notification/fetch-all'(data: { login: Im.Login }): Promise> + + 'im/v1/invitation/friend'(data: { + login: Im.Login + target: string + content: string + }): Promise + 'im/v1/invitation/member'(data: { + login: Im.Login + target: string + content: string + gid: string + }): Promise + 'im/v1/invitation/reply'(data: { login: Im.Login; nid: string; reply: boolean }): Promise + + 'im/v1/file-upload'(data: { login: Im.Login; b64: string }): Promise + 'im/v1/avatar-upload'(data: { login: Im.Login; b64: string; gid?: string }): Promise + } } export interface Config {} +export const inject = ['webui', 'server', 'im', 'im.event', 'im.auth', 'im.data'] + export const Config: Schema = Schema.object({}) export function apply(ctx: Context) { - ctx.webui.addEntry({ - base: import.meta.url, - dev: '../client/index.ts', - prod: [ - '../dist/index.js', - '../dist/style.css', - ], + ctx.plugin(EntryService) + + ctx.inject(['im.entry'], (ctx) => { + ctx.webui.addListener('im/v1/login', async function (data: { name: string; password: string }) { + // @ts-ignore + const clientId = this['x-client-id'] + return ctx.im.auth.authenticate(data.name, data.password, clientId) + }) // HACK: type ignorance + ctx.webui.addListener('im/v1/login-add', async function (data: { login: Im.Login }) { + // @ts-ignore + data.login.clientId = this['x-client-id'] + return ctx.im.auth.authenticateWithToken(data.login) + }) // HACK: type ignorance + ctx.webui.addListener('im/v1/login-remove', v1Wrapper(ctx.im.auth.logout)) + ctx.webui.addListener('im/v1/register', v1Wrapper(ctx.im.auth.register)) + ctx.webui.addListener('im/v1/validate-name', v1Wrapper(ctx.im.auth.isNameUnique)) + + ctx['im.entry'].addListenerWithAuth( + 'im/v1/invitation/friend', + v1Wrapper(ctx.im.data.notification.request) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/invitation/member', + v1Wrapper(ctx.im.data.notification.request) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/invitation/reply', + v1Wrapper(ctx.im.data.notification.reply) + ) + + ctx['im.entry'].addListenerWithAuth('im/v1/channel/fetch', v1Wrapper(ctx.im.data.channel.fetch)) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/channel/create', + v1Wrapper(ctx.im.data.channel.create) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/channel/update', + v1Wrapper(ctx.im.data.channel.update) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/channel/remove', + v1Wrapper(ctx.im.data.channel.softDel) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/channel/update-settings', + v1Wrapper(ctx.im.data.channel.updateSettings) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/channel/recent', + v1Wrapper(ctx.im.data.channel.fetchSessionList) + ) + + ctx['im.entry'].addListenerWithAuth('im/v1/friend/fetch', v1Wrapper(ctx.im.data.friend.fetch)) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/friend/fetch-all', + v1Wrapper(ctx.im.data.friend.list) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/friend/remove', + v1Wrapper(ctx.im.data.friend.softDel) + ) + ctx['im.entry'].addListenerWithAuth('im/v1/search/friend', v1Wrapper(ctx.im.data.friend.search)) + + ctx['im.entry'].addListenerWithAuth('im/v1/guild/fetch', v1Wrapper(ctx.im.data.guild.fetch)) + ctx['im.entry'].addListenerWithAuth('im/v1/guild/fetch-all', v1Wrapper(ctx.im.data.guild.list)) + ctx['im.entry'].addListenerWithAuth('im/v1/guild/create', v1Wrapper(ctx.im.data.guild.create)) + ctx['im.entry'].addListenerWithAuth('im/v1/guild/update', v1Wrapper(ctx.im.data.guild.update)) + ctx['im.entry'].addListenerWithAuth('im/v1/search/guild', v1Wrapper(ctx.im.data.guild.search)) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-member/fetch', + v1Wrapper(ctx.im.data.guild.Member.fetch) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-member/fetch-all', + v1Wrapper(ctx.im.data.guild.Member.list) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-member/update', + v1Wrapper(ctx.im.data.guild.Member.update) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-member/kick', + v1Wrapper(ctx.im.data.guild.Member.kick) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-role/fetch', + v1Wrapper(ctx.im.data.guild.Role.fetch) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-role/fetch-all', + v1Wrapper(ctx.im.data.guild.Role.list) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-role/create', + v1Wrapper(ctx.im.data.guild.Role.create) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-role/update', + v1Wrapper(ctx.im.data.guild.Role.update) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/guild-role/remove', + v1Wrapper(ctx.im.data.guild.Role.hardDel) + ) + + ctx['im.entry'].addListenerWithAuth('im/v1/message/fetch', v1Wrapper(ctx.im.data.message.fetch)) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/message/create', + v1Wrapper(ctx.im.data.message.create) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/message/update', + v1Wrapper(ctx.im.data.message.update) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/message/recall', + v1Wrapper(ctx.im.data.message.softDel) + ) + ctx['im.entry'].addListenerWithAuth( + 'im/v1/message/unread-count', + v1Wrapper(ctx.im.data.message.getUnreadCount) + ) + + ctx['im.entry'].addListenerWithAuth( + 'im/v1/notification/fetch-all', + v1Wrapper(ctx.im.data.notification.list) + ) + + ctx['im.entry'].addListenerWithAuth('im/v1/user/fetch', v1Wrapper(ctx.im.data.user.fetch)) + ctx['im.entry'].addListenerWithAuth('im/v1/user/update', v1Wrapper(ctx.im.data.user.update)) + ctx['im.entry'].addListenerWithAuth('im/v1/search/user', v1Wrapper(ctx.im.data.user.search)) + + ctx['im.entry'].addListenerWithAuth('im/v1/file-upload', v1Wrapper(ctx.im.data.writeFile)) + + ctx['im.entry'].addListenerWithAuth('im/v1/avatar-upload', v1Wrapper(ctx.im.data.writeAvatar)) + + ctx['im.entry'].addListenerWithAuth( + 'im/v1/bot/fetch-commands', + v1Wrapper(ctx.im.data.bot.fetchCommands) + ) }) } diff --git a/packages/im/tsconfig.json b/packages/im/tsconfig.json index e193a11..fd164e6 100644 --- a/packages/im/tsconfig.json +++ b/packages/im/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "lib", }, "include": [ - "src", + "src" ], -} \ No newline at end of file +} \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..122fbf3 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,38 @@ +{ + "name": "@satorijs/plugin-im-utils", + "description": "Utils of Satori IM", + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "files": [ + "lib", + "dist" + ], + "author": "Shigma ", + "license": "MIT", + "scripts": { + "lint": "eslint src --ext .ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/koishijs/koishi-plugin-im.git", + "directory": "packages/utils" + }, + "bugs": { + "url": "https://github.com/koishijs/koishi-plugin-im/issues" + }, + "keywords": [ + "satori", + "plugin", + "chat", + "im", + "database" + ], + "devDependencies": { + "@types/uuid": "^10.0.0" + }, + "dependencies": { + "cordis": "^3.18.0", + "uuid": "^10.0.0" + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000..3f6a330 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,26 @@ +import { Schema } from 'cordis' + +export { v4 as genId } from 'uuid' + +const _patterns = { + name: Schema.string().pattern(/^[a-zA-Z0-9_]{1,16}$/), + nick: Schema.string().pattern(/^[^<>?/*'"\\]{1,16}$/), + password: Schema.string().pattern(/^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[\W_]).{8,16}$/), +} + +export function validate(type: keyof typeof _patterns, value: string): boolean { + try { + _patterns[type](value) + } catch (e) { + return false + } + return true +} + +export function v1Wrapper( + callback: (...args: any[]) => Promise +): (data: T) => Promise { + return async function (data: T) { + return callback(...Object.values(data)) + } +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000..fd164e6 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + }, + "include": [ + "src" + ], +} \ No newline at end of file