├── packages ├── bin │ ├── tsconfig.json │ ├── index.ts │ ├── package.json │ └── scripts │ │ ├── getUserId.ts │ │ ├── updateDefaultGroupName.ts │ │ ├── deleteTodayRegisteredUsers.ts │ │ ├── fixUsersAvatar.ts │ │ ├── doctor.ts │ │ ├── deleteMessages.ts │ │ └── register.ts ├── database │ ├── mongoose │ │ ├── index.ts │ │ ├── models │ │ │ ├── notification.ts │ │ │ ├── friend.ts │ │ │ ├── socket.ts │ │ │ ├── history.ts │ │ │ ├── group.ts │ │ │ ├── user.ts │ │ │ └── message.ts │ │ └── initMongoDB.ts │ ├── tsconfig.json │ ├── package.json │ └── redis │ │ └── initRedis.ts ├── server │ ├── src │ │ ├── types │ │ │ ├── index.d.ts │ │ │ └── server.d.ts │ │ ├── middlewares │ │ │ ├── isLogin.ts │ │ │ ├── seal.ts │ │ │ ├── isAdmin.ts │ │ │ ├── registerRoutes.ts │ │ │ └── frequency.ts │ │ ├── routes │ │ │ ├── notification.ts │ │ │ └── history.ts │ │ └── app.ts │ ├── tsconfig.json │ ├── public │ │ ├── favicon-96.png │ │ ├── favicon-192.png │ │ ├── favicon-512.png │ │ ├── index.html │ │ └── manifest.json │ ├── .nodemonrc │ ├── test │ │ ├── helpers │ │ │ └── middleware.ts │ │ └── middlewares │ │ │ ├── isAdmin.spec.ts │ │ │ ├── isLogin.spec.ts │ │ │ ├── seal.spec.ts │ │ │ └── frequency.spec.ts │ └── package.json ├── web │ ├── src │ │ ├── styles │ │ │ ├── variable.less │ │ │ └── normalize.less │ │ ├── components │ │ │ ├── Loading.tsx │ │ │ ├── Menu.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── Select.tsx │ │ │ ├── Dropdown.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── Dropdown.less │ │ │ ├── Progress.tsx │ │ │ ├── IconButton.less │ │ │ ├── Tabs.tsx │ │ │ ├── Tooltip.less │ │ │ ├── Input.less │ │ │ ├── Message.less │ │ │ ├── IconButton.tsx │ │ │ ├── Button.tsx │ │ │ ├── Message.tsx │ │ │ ├── Dialog.less │ │ │ ├── Avatar.tsx │ │ │ └── Input.tsx │ │ ├── context.ts │ │ ├── modules │ │ │ ├── FunctionBarAndLinkmanList │ │ │ │ ├── LinkmanList.less │ │ │ │ ├── CreateGroup.less │ │ │ │ ├── FunctionBarAndLinkmanList.less │ │ │ │ ├── FunctionBarAndLinkmanList.tsx │ │ │ │ ├── Linkman.less │ │ │ │ ├── CreateGroup.tsx │ │ │ │ └── LinkmanList.tsx │ │ │ ├── Sidebar │ │ │ │ ├── OnlineStatus.less │ │ │ │ ├── About.less │ │ │ │ ├── Download.less │ │ │ │ ├── OnlineStatus.tsx │ │ │ │ ├── Admin.less │ │ │ │ ├── SelfInfo.less │ │ │ │ ├── Common.less │ │ │ │ ├── Sidebar.less │ │ │ │ ├── Download.tsx │ │ │ │ ├── About.tsx │ │ │ │ └── Setting.less │ │ │ ├── Chat │ │ │ │ ├── MessageList.less │ │ │ │ ├── Message │ │ │ │ │ ├── UrlMessage.tsx │ │ │ │ │ ├── InviteMessage.less │ │ │ │ │ ├── SystemMessage.tsx │ │ │ │ │ ├── CodeDialog.tsx │ │ │ │ │ ├── InviteMessageV2.tsx │ │ │ │ │ ├── FileMessage.tsx │ │ │ │ │ ├── CodeMessage.less │ │ │ │ │ ├── CodeMessage.tsx │ │ │ │ │ └── TextMessage.tsx │ │ │ │ ├── CodeEditor.less │ │ │ │ ├── Chat.less │ │ │ │ ├── HeaderBar.less │ │ │ │ ├── Expression.less │ │ │ │ └── GroupManagePanel.less │ │ │ ├── LoginAndRegister │ │ │ │ ├── LoginAndRegister.less │ │ │ │ ├── LoginAndRegister.tsx │ │ │ │ ├── LoginRegister.less │ │ │ │ ├── Register.tsx │ │ │ │ └── Login.tsx │ │ │ ├── InfoDialog.less │ │ │ └── GroupInfo.tsx │ │ ├── state │ │ │ ├── store.ts │ │ │ └── action.ts │ │ ├── hooks │ │ │ ├── useAero.ts │ │ │ ├── useIsLogin.ts │ │ │ └── useStore.ts │ │ ├── globalStyles.ts │ │ ├── App.less │ │ ├── themes.ts │ │ ├── utils │ │ │ ├── notification.ts │ │ │ ├── setCssVariable.ts │ │ │ ├── fetch.ts │ │ │ ├── playSound.ts │ │ │ ├── voice.ts │ │ │ ├── uploadFile.ts │ │ │ ├── inobounce.ts │ │ │ └── readDiskFile.ts │ │ ├── template.html │ │ ├── types │ │ │ └── index.d.ts │ │ ├── main.tsx │ │ └── localStorage.ts │ ├── tsconfig.json │ ├── build │ │ ├── webpack.dev.js │ │ └── webpack.prod.js │ ├── test │ │ ├── localStorage.spec.ts │ │ └── components │ │ │ ├── Button.spec.tsx │ │ │ └── Avatar.spec.tsx │ ├── .babelrc │ └── package.json ├── assets │ ├── audios │ │ ├── apple.mp3 │ │ ├── huaji.mp3 │ │ ├── momo.mp3 │ │ ├── pcqq.mp3 │ │ ├── default.mp3 │ │ └── mobileqq.mp3 │ ├── fonts │ │ └── font.woff │ ├── images │ │ ├── baidu.png │ │ ├── background.jpg │ │ ├── wuzeiniang.gif │ │ └── no-linkman.jpeg │ └── package.json ├── utils │ ├── sleep.ts │ ├── xss.ts │ ├── logger.ts │ ├── ua.ts │ ├── socket.ts │ ├── test │ │ ├── getFriendId.spec.ts │ │ └── url.spec.ts │ ├── getFriendId.ts │ ├── package.json │ ├── url.ts │ ├── const.ts │ ├── compressImage.ts │ ├── getRandomColor.ts │ ├── expressions.ts │ ├── time.ts │ ├── convertMessage.ts │ └── snowflake.ts └── config │ ├── package.json │ ├── yarn.lock │ ├── client.ts │ └── server.ts ├── .eslintignore ├── .dockerignore ├── lerna.json ├── .prettierrc ├── jest.setup.js ├── jest.transformer.js ├── docker-compose-redis.yaml ├── docker-compose-mongo.yaml ├── Dockerfile ├── tsconfig.json ├── docker-compose.yaml ├── .gitignore ├── jest.config.js ├── LICENSE ├── README_ZH.md ├── .env.example ├── README.md ├── .eslintrc └── package.json /packages/bin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } -------------------------------------------------------------------------------- /packages/database/mongoose/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'mongoose'; 2 | -------------------------------------------------------------------------------- /packages/database/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } -------------------------------------------------------------------------------- /packages/server/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'regex-escape'; 2 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | public/ 4 | build/ 5 | docs/ 6 | *.d.ts 7 | -------------------------------------------------------------------------------- /packages/web/src/styles/variable.less: -------------------------------------------------------------------------------- 1 | @mobile: ~"only screen and (max-width: 500px)"; -------------------------------------------------------------------------------- /packages/web/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import Loading from 'react-loading'; 2 | 3 | export default Loading; 4 | -------------------------------------------------------------------------------- /packages/assets/audios/apple.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/audios/apple.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/huaji.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/audios/huaji.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/momo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/audios/momo.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/pcqq.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/audios/pcqq.mp3 -------------------------------------------------------------------------------- /packages/assets/fonts/font.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/fonts/font.woff -------------------------------------------------------------------------------- /packages/assets/images/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/images/baidu.png -------------------------------------------------------------------------------- /packages/assets/audios/default.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/audios/default.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/mobileqq.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/audios/mobileqq.mp3 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_module 2 | packages/docs/ 3 | packages/web/.linaria-cache/ 4 | packages/web/dist/ 5 | 6 | yarn-error.log -------------------------------------------------------------------------------- /packages/assets/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/images/background.jpg -------------------------------------------------------------------------------- /packages/assets/images/wuzeiniang.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/images/wuzeiniang.gif -------------------------------------------------------------------------------- /packages/server/public/favicon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/server/public/favicon-96.png -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent", 6 | "npmClient": "yarn" 7 | } 8 | -------------------------------------------------------------------------------- /packages/assets/images/no-linkman.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/assets/images/no-linkman.jpeg -------------------------------------------------------------------------------- /packages/server/public/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/server/public/favicon-192.png -------------------------------------------------------------------------------- /packages/server/public/favicon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gochendong/bulita/HEAD/packages/server/public/favicon-512.png -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | } 6 | } -------------------------------------------------------------------------------- /packages/server/.nodemonrc: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": [ 4 | "test/**/*", 5 | "public/**/*" 6 | ] 7 | } -------------------------------------------------------------------------------- /packages/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/assets", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "printWidth": 80 7 | } -------------------------------------------------------------------------------- /packages/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(duration = 200) { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, duration); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/src/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import Menu, { SubMenu, MenuItem } from 'rc-menu'; 2 | import 'rc-menu/assets/index.css'; 3 | 4 | export { Menu, MenuItem, SubMenu }; 5 | -------------------------------------------------------------------------------- /packages/web/src/components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import Dialog from 'rc-dialog'; 2 | import 'rc-dialog/assets/index.css'; 3 | 4 | import './Dialog.less'; 5 | 6 | export default Dialog; 7 | -------------------------------------------------------------------------------- /packages/web/src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import Select, { Option, OptGroup } from 'rc-select'; 2 | import 'rc-select/assets/index.css'; 3 | 4 | export { Select, Option, OptGroup }; 5 | -------------------------------------------------------------------------------- /packages/web/src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import Dropdown from 'rc-dropdown'; 2 | import 'rc-dropdown/assets/index.css'; 3 | 4 | import './Dropdown.less'; 5 | 6 | export default Dropdown; 7 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import Tooltip from 'rc-tooltip'; 2 | import 'rc-tooltip/assets/bootstrap.css'; 3 | 4 | import './Tooltip.less'; 5 | 6 | export default Tooltip; 7 | -------------------------------------------------------------------------------- /packages/web/src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const ShowUserOrGroupInfoContext = createContext(null); 5 | -------------------------------------------------------------------------------- /packages/utils/xss.ts: -------------------------------------------------------------------------------- 1 | import xss from 'xss'; 2 | 3 | /** 4 | * xss防护 5 | * @param text 要处理的文字 6 | */ 7 | export default function processXss(text: string) { 8 | return xss(text); 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/src/components/Dropdown.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .rc-dropdown { 3 | max-width: 100%; 4 | } 5 | 6 | .rc-select-dropdown { 7 | z-index: 1500 !important; 8 | } 9 | } -------------------------------------------------------------------------------- /packages/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from 'log4js'; 2 | 3 | const logger = getLogger(); 4 | logger.level = process.env.NODE_ENV === 'development' ? 'trace' : 'info'; 5 | 6 | export default logger; 7 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.mock('./packages/web/node_modules/linaria', () => ({ 2 | css: jest.fn(() => ''), 3 | })); 4 | 5 | jest.mock('./packages/database/node_modules/redis', () => jest.requireActual('redis-mock')); 6 | -------------------------------------------------------------------------------- /jest.transformer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | process(src, filename) { 5 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/web/src/components/Progress.tsx: -------------------------------------------------------------------------------- 1 | import { Line as LineProgress, Circle as CircleProgress } from 'rc-progress'; 2 | import 'rc-progress/assets/index.css'; 3 | 4 | export { LineProgress, CircleProgress }; 5 | -------------------------------------------------------------------------------- /packages/utils/ua.ts: -------------------------------------------------------------------------------- 1 | const UA = window.navigator.userAgent; 2 | 3 | export const isiOS = /iPhone/i.test(UA); 4 | 5 | export const isAndroid = /android/i.test(UA); 6 | 7 | export const isMobile = isiOS || isAndroid; 8 | -------------------------------------------------------------------------------- /docker-compose-redis.yaml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | bulita-redis: 5 | image: redis 6 | restart: always 7 | ports: 8 | - 6379:6379 9 | volumes: 10 | - ./data/redis:/data 11 | 12 | -------------------------------------------------------------------------------- /packages/web/src/components/IconButton.less: -------------------------------------------------------------------------------- 1 | .iconButton { 2 | text-align: center; 3 | color: rgba(165, 181, 192, 1); 4 | cursor: pointer; 5 | 6 | &:hover { 7 | color: rgba(243, 243, 243, 1); 8 | } 9 | } -------------------------------------------------------------------------------- /docker-compose-mongo.yaml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | bulita-mongodb: 5 | image: mongo 6 | restart: always 7 | ports: 8 | - "27017:27017" 9 | volumes: 10 | - ./data/mongodb:/data/db 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/LinkmanList.less: -------------------------------------------------------------------------------- 1 | .linkmanList { 2 | flex: 1; 3 | overflow-y: auto; 4 | -webkit-overflow-scrolling: touch; 5 | //background-color: rgb(247, 247, 247); 3.13 ui 6 | background-color: white; 7 | } -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/config", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "ip": "^1.1.5" 8 | }, 9 | "devDependencies": { 10 | "@types/ip": "^1.1.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | WORKDIR /usr/app/bulita 4 | 5 | COPY packages ./packages 6 | COPY .env ./packages/web 7 | 8 | COPY package.json tsconfig.json yarn.lock lerna.json .env ./ 9 | 10 | RUN yarn install 11 | 12 | RUN yarn build:web 13 | 14 | CMD yarn start 15 | -------------------------------------------------------------------------------- /packages/server/test/helpers/middleware.ts: -------------------------------------------------------------------------------- 1 | export function getMiddlewareParams(event = 'login', data = {}) { 2 | const cb = jest.fn(); 3 | const next = jest.fn(); 4 | 5 | return { 6 | args: [event, data, cb], 7 | cb, 8 | next, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/src/state/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import reducer from './reducer'; 3 | 4 | const store = createStore( 5 | reducer, 6 | window.__REDUX_DEVTOOLS_EXTENSION__ && 7 | window.__REDUX_DEVTOOLS_EXTENSION__(), 8 | ); 9 | export default store; 10 | -------------------------------------------------------------------------------- /packages/web/src/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import Tabs, { TabPane } from 'rc-tabs'; 2 | import TabContent from 'rc-tabs/lib/TabContent'; 3 | import ScrollableInkTabBar from 'rc-tabs/lib/ScrollableInkTabBar'; 4 | import 'rc-tabs/assets/index.css'; 5 | 6 | export { Tabs, TabPane, TabContent, ScrollableInkTabBar }; 7 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .rc-tooltip { 3 | display: inline-block !important; 4 | } 5 | .rc-tooltip-hidden { 6 | display: none !important; 7 | } 8 | .rc-tooltip-inner { 9 | span { 10 | color: #f1f1f1 !important; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /packages/web/src/hooks/useAero.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State } from '../state/reducer'; 3 | 4 | /** 5 | * 获取毛玻璃状态属性 6 | */ 7 | export default function useAero() { 8 | const aero = useSelector((state: State) => state.status.aero); 9 | return { 10 | 'data-aero': aero, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/OnlineStatus.less: -------------------------------------------------------------------------------- 1 | .onlineStatus { 2 | width: 16px; 3 | height: 16px; 4 | background-color: var(--primary-color-8); 5 | border-radius: 50%; 6 | } 7 | 8 | .status { 9 | width: 12px; 10 | height: 12px; 11 | margin-top: 2px; 12 | margin-left: 2px; 13 | border-radius: 50%; 14 | } -------------------------------------------------------------------------------- /packages/web/src/hooks/useIsLogin.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State } from '../state/reducer'; 3 | 4 | /** 5 | * 获取登录态 6 | */ 7 | export default function useIsLogin() { 8 | const isLogin = useSelector( 9 | (state: State) => state.user && state.user._id !== '', 10 | ); 11 | return isLogin; 12 | } 13 | -------------------------------------------------------------------------------- /packages/utils/socket.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | export function getSocketIp(socket: Socket) { 4 | return ( 5 | (socket.handshake.headers['x-forwarded-for'] as string) || 6 | (socket.handshake.headers['x-real-ip'] as string) || 7 | socket.request.connection.remoteAddress || 8 | '' 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 默认首页 8 | 9 | 10 |

请执行 "yarn build:web" 构建前端页面

11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/MessageList.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .messageList { 4 | width: 100%; 5 | height: 100%; 6 | padding: 8px 10px 0 10px; 7 | overflow-y: auto; 8 | overflow-x: hidden; 9 | -webkit-overflow-scrolling: touch; 10 | position: relative; 11 | 12 | @media @mobile { 13 | padding: 8px 6px 0 6px; 14 | } 15 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/About.less: -------------------------------------------------------------------------------- 1 | @import "../../styles/variable.less"; 2 | 3 | .about { 4 | width: 600px !important; 5 | 6 | @media @mobile { 7 | width: 94vw !important; 8 | } 9 | 10 | :global { 11 | p, li { 12 | user-select: text; 13 | } 14 | a { 15 | word-break: break-all; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/src/globalStyles.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { css } from 'linaria'; 3 | 4 | const globalStyles = css` 5 | :global() { 6 | .danger { 7 | background-color: #dd514c !important; 8 | 9 | &:hover { 10 | background-color: #d7342e !important; 11 | } 12 | } 13 | } 14 | `; 15 | 16 | export default globalStyles; 17 | -------------------------------------------------------------------------------- /packages/utils/test/getFriendId.spec.ts: -------------------------------------------------------------------------------- 1 | import getFriendId from '../getFriendId'; 2 | 3 | describe('utils/getFriendId.ts', () => { 4 | it('should combina two users id as friend id', () => { 5 | const user1 = '111'; 6 | const user2 = '222'; 7 | expect(getFriendId(user1, user2)).toBe('111222'); 8 | expect(getFriendId(user2, user1)).toBe('111222'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/utils/getFriendId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Combina two users id as frind id 3 | * The result has nothing to do with the order of the parameters 4 | * @param userId1 user id 5 | * @param userId2 user id 6 | */ 7 | export default function getFriendId(userId1: string, userId2: string) { 8 | if (userId1 < userId2) { 9 | return userId1 + userId2; 10 | } 11 | return userId2 + userId1; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/UrlMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface UrlMessageProps { 4 | url: string; 5 | } 6 | 7 | function UrlMessage(props: UrlMessageProps) { 8 | const { url } = props; 9 | return ( 10 | 11 | {url} 12 | 13 | ); 14 | } 15 | 16 | export default UrlMessage; 17 | -------------------------------------------------------------------------------- /packages/database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/database", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "@bulita/config": "^1.0.0", 8 | "@bulita/utils": "^1.0.0", 9 | "mongoose": "^5.13.3", 10 | "redis": "^3.1.2" 11 | }, 12 | "devDependencies": { 13 | "@types/mongoose": "^5.11.97", 14 | "@types/redis": "^2.8.31" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Download.less: -------------------------------------------------------------------------------- 1 | .download { 2 | 3 | } 4 | 5 | .android { 6 | p, a { 7 | font-size: 14px; 8 | } 9 | img { 10 | margin-top: 8px; 11 | } 12 | } 13 | 14 | .ios { 15 | p { 16 | font-size: 14px; 17 | line-height: 20px; 18 | } 19 | img { 20 | width: 400px; 21 | height: auto; 22 | margin-top: 8px; 23 | } 24 | } -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/utils", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "@bulita/assets": "^1.0.0", 8 | "ali-oss": "^6.16.0", 9 | "axios": "^0.21.1", 10 | "log4js": "^6.3.0", 11 | "randomcolor": "^0.6.2", 12 | "socket.io": "^4.1.3", 13 | "xss": "^1.0.9" 14 | }, 15 | "devDependencies": { 16 | "@types/randomcolor": "^0.5.6" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/src/App.less: -------------------------------------------------------------------------------- 1 | @import "./styles/variable.less"; 2 | 3 | .app { 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | } 8 | 9 | .blur, .child { 10 | position: absolute; 11 | } 12 | 13 | .blur { 14 | filter: blur(10px); 15 | } 16 | 17 | .child { 18 | display: flex; 19 | border-radius: 10px; 20 | box-shadow: 0px 0px 60px rgba(0, 0, 0, 0.5); 21 | 22 | @media @mobile { 23 | border-radius: 0; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "strict": true, 5 | "moduleResolution": "node", 6 | "experimentalDecorators": true, 7 | "jsx": "react", 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "lib": ["es2015", "es2016", "es2017", "dom"], 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true 13 | }, 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/bin/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | const script = process.argv[2]; 6 | if (!script) { 7 | console.log(chalk.green('没有任何事发生~')); 8 | process.exit(0); 9 | } 10 | 11 | const file = path.resolve(__dirname, `scripts/${script}.ts`); 12 | if (!fs.existsSync(file)) { 13 | console.log(chalk.red(`[${script}] 脚本不存在`)); 14 | } 15 | 16 | // @ts-ignore 17 | import(file).then((module) => { 18 | module.default(); 19 | }); 20 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | bulita: 5 | build: . 6 | restart: always 7 | ports: 8 | - "9200:9200" 9 | volumes: 10 | - ./data/ImageMessage:/usr/app/bulita/packages/server/public/ImageMessage 11 | - ./data/FileMessage:/usr/app/bulita/packages/server/public/FileMessage 12 | - ./data/GroupAvatar:/usr/app/bulita/packages/server/public/GroupAvatar 13 | - ./data/Avatar:/usr/app/bulita/packages/server/public/Avatar 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/utils/url.ts: -------------------------------------------------------------------------------- 1 | interface UrlParams { 2 | [key: string]: string; 3 | } 4 | 5 | // eslint-disable-next-line import/prefer-default-export 6 | export function addParam(url: string, params: UrlParams) { 7 | let result = url; 8 | Object.keys(params).forEach((key) => { 9 | if (result.indexOf('?') === -1) { 10 | result += `?${key}=${params[key]}`; 11 | } else { 12 | result += `&${key}=${params[key]}`; 13 | } 14 | }); 15 | return result; 16 | } 17 | -------------------------------------------------------------------------------- /packages/utils/const.ts: -------------------------------------------------------------------------------- 1 | /** 封禁后提示文案 */ 2 | export const SEAL_TEXT = '你已经被关进小黑屋中, 请稍后再试'; 3 | 4 | /** 透明图 */ 5 | export const TRANSPARENT_IMAGE = 6 | 'data:image/png;base64,R0lGODlhFAAUAIAAAP///wAAACH5BAEAAAAALAAAAAAUABQAAAIRhI+py+0Po5y02ouz3rz7rxUAOw=='; 7 | 8 | /** 加密salt位数 */ 9 | export const SALT_ROUNDS = 10; 10 | 11 | export const MB = 1024 * 1024; 12 | 13 | // export const NAME_REGEXP = /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]|[\u3040-\u309Fー]|[\u30A0-\u30FF]){1,8}$/; 14 | export const NAME_REGEXP = /^.{1,20}$/; 15 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/InviteMessage.less: -------------------------------------------------------------------------------- 1 | .inviteMessage { 2 | width: 160px; 3 | padding: 0 4px; 4 | text-align: center; 5 | cursor: pointer; 6 | color: var(--primary-text-color-10); 7 | } 8 | 9 | .info { 10 | display: flex; 11 | border-bottom: 1px solid #eee; 12 | align-items: center; 13 | } 14 | 15 | .infoText { 16 | line-height: 18px; 17 | } 18 | 19 | .join { 20 | display: inline-block; 21 | font-size: 12px; 22 | text-align: center; 23 | margin-top: 6px; 24 | } -------------------------------------------------------------------------------- /packages/web/src/themes.ts: -------------------------------------------------------------------------------- 1 | import BackgroundImage from '@bulita/assets/images/background.jpg'; 2 | 3 | type Themes = { 4 | [theme: string]: { 5 | primaryColor: string; 6 | primaryTextColor: string; 7 | backgroundImage: string; 8 | aero: boolean; 9 | }; 10 | }; 11 | 12 | const themes: Themes = { 13 | default: { 14 | primaryColor: '9, 188, 139', 15 | primaryTextColor: '0, 0, 0', 16 | backgroundImage: BackgroundImage, 17 | aero: false, 18 | }, 19 | }; 20 | 21 | export default themes; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | coverage/ 5 | .idea/ 6 | .linaria-cache/ 7 | data 8 | 9 | npm-debug.log 10 | yarn-error.log 11 | .eslintcache 12 | lerna-debug.log 13 | 14 | .env 15 | 16 | packages/server/public/* 17 | !packages/server/public/avatar/ 18 | packages/server/public/avatar/*_*.* 19 | !packages/server/public/favicon-96.png 20 | !packages/server/public/favicon-192.png 21 | !packages/server/public/favicon-512.png 22 | !packages/server/public/manifest.json 23 | !packages/server/public/index.html 24 | !packages/server/public/PrivacyPolicy.html -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | moduleNameMapper: { 4 | '^.+\\.(css|less|jpg|png|gif|mp3)$': '/jest.transformer.js', 5 | }, 6 | collectCoverage: true, 7 | globals: { 8 | 'ts-jest': { 9 | isolatedModules: true, 10 | }, 11 | __TEST__: true, 12 | }, 13 | setupFilesAfterEnv: ['./jest.setup.js'], 14 | collectCoverageFrom: [ 15 | '**/*.{ts,tsx}', 16 | '!**/node_modules/**', 17 | '!**/config/**', 18 | '!**/test/helpers/**', 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /packages/web/src/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State, Linkman } from '../state/reducer'; 3 | 4 | export function useStore() { 5 | return useSelector((state: State) => state); 6 | } 7 | 8 | export function useFocusLinkman(): Linkman | null { 9 | const store = useStore(); 10 | const { focus } = store; 11 | if (focus) { 12 | return store.linkmans?.[focus]; 13 | } 14 | return null; 15 | } 16 | 17 | export function useSelfId() { 18 | const store = useStore(); 19 | return store.user?._id || ''; 20 | } 21 | -------------------------------------------------------------------------------- /packages/bin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/bin", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "script": "ts-node --transpile-only index.ts" 8 | }, 9 | "dependencies": { 10 | "@bulita/config": "^1.0.0", 11 | "@bulita/database": "^1.0.0", 12 | "bcryptjs": "^2.4.3", 13 | "chalk": "^4.1.1", 14 | "detect-port": "^1.3.0", 15 | "inquirer": "^8.1.2" 16 | }, 17 | "devDependencies": { 18 | "@types/bcryptjs": "^2.4.2", 19 | "@types/detect-port": "^1.3.1", 20 | "@types/inquirer": "^7.3.3" 21 | } 22 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/OnlineStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Style from './OnlineStatus.less'; 4 | 5 | interface OnlineStatusProps { 6 | /** 状态, online / offline */ 7 | status: string; 8 | className?: string; 9 | } 10 | 11 | function OnlineStatus(props: OnlineStatusProps) { 12 | const { status, className } = props; 13 | 14 | return ( 15 |
16 |
17 |
18 | ); 19 | } 20 | 21 | export default OnlineStatus; 22 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Admin.less: -------------------------------------------------------------------------------- 1 | .admin { 2 | // height: 50% !important; 3 | } 4 | 5 | .inputBlock { 6 | display: flex; 7 | } 8 | 9 | .input { 10 | flex: 1; 11 | height: 36px; 12 | } 13 | 14 | .tagUsernameInput { 15 | flex: 3; 16 | } 17 | 18 | .tagInput { 19 | flex: 2; 20 | margin-left: 6px; 21 | } 22 | 23 | .button { 24 | width: 100px; 25 | height: 36px; 26 | margin-left: 10px; 27 | } 28 | 29 | .sealList { 30 | min-height: 22px; 31 | } 32 | 33 | .sealUsername { 34 | margin-right: 10px; 35 | line-height: 22px; 36 | font-size: 14px; 37 | } -------------------------------------------------------------------------------- /packages/web/src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | export default function notification( 2 | title: string, 3 | icon: string, 4 | body: string, 5 | tag = 'tag', 6 | duration = 3000, 7 | ) { 8 | if (window.Notification && window.Notification.permission === 'granted') { 9 | const n = new window.Notification(title, { 10 | icon, 11 | body, 12 | tag, 13 | }); 14 | n.onclick = function handleClick() { 15 | window.focus(); 16 | this.close(); 17 | }; 18 | setTimeout(n.close.bind(n), duration); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/SystemMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getPerRandomColor } from '@bulita/utils/getRandomColor'; 3 | 4 | interface SystemMessageProps { 5 | message: string; 6 | username: string; 7 | } 8 | 9 | function SystemMessage(props: SystemMessageProps) { 10 | const { message, username } = props; 11 | return ( 12 |
13 | 14 | {username} 15 | 16 |   17 | {message} 18 |
19 | ); 20 | } 21 | 22 | export default SystemMessage; 23 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/notification.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const NotificationSchema = new Schema({ 4 | createTime: { type: Date, default: Date.now }, 5 | 6 | user: { 7 | type: Schema.Types.ObjectId, 8 | ref: 'User', 9 | }, 10 | token: { 11 | type: String, 12 | unique: true, 13 | }, 14 | }); 15 | 16 | export interface NotificationDocument extends Document { 17 | user: any; 18 | token: string; 19 | } 20 | 21 | const Notification = model( 22 | 'Notification', 23 | NotificationSchema, 24 | ); 25 | 26 | export default Notification; 27 | -------------------------------------------------------------------------------- /packages/web/src/components/Input.less: -------------------------------------------------------------------------------- 1 | .inputContainer { 2 | position: relative; 3 | } 4 | 5 | .input { 6 | width: 100%; 7 | height: 100%; 8 | border-radius: 6px; 9 | border: 1px solid rgba(0, 0, 0, 0.2); 10 | //padding: 0 34px 0 8px; 11 | font-size: 14px; 12 | color: #333; 13 | box-sizing: border-box; 14 | user-select: auto; 15 | 16 | &:focus { 17 | border-color: var(--primary-color-10); 18 | } 19 | } 20 | 21 | .inputIconButton { 22 | position: absolute; 23 | top: 0; 24 | bottom: 0; 25 | margin: auto; 26 | right: 5px; 27 | 28 | &:hover { 29 | color: var(--primary-color-10); 30 | } 31 | } -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/LoginAndRegister.less: -------------------------------------------------------------------------------- 1 | .login { 2 | border-bottom: none; 3 | width: 100%; 4 | 5 | :global { 6 | .rc-tabs-nav-wrap { 7 | width: 166px; // 70px 8 | margin: 0 auto; 9 | } 10 | .rc-tabs-tab { 11 | font-size: 16px; 12 | } 13 | } 14 | } 15 | 16 | //.login { 17 | // border-bottom: none; 18 | // width: 100%; 19 | // 20 | // :global { 21 | // .rc-tabs-nav-wrap { 22 | // width: 166px; 23 | // margin: 0 auto; 24 | // } 25 | // .rc-tabs-tab { 26 | // font-size: 16px; 27 | // } 28 | // } 29 | //} 30 | -------------------------------------------------------------------------------- /packages/web/src/utils/setCssVariable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * set global css variable 3 | * @param color primary color, three numbers split with comma, like 255,255,255 4 | * @param textColor text colore, format like color 5 | */ 6 | export default function setCssVariable(color: string, textColor: string) { 7 | let cssText = ''; 8 | for (let i = 0; i <= 10; i++) { 9 | cssText += `--primary-color-${i}:rgba(${color}, ${ 10 | i / 10 11 | });--primary-color-${i}_5:rgba(${color}, ${ 12 | (i + 0.5) / 10 13 | });--primary-text-color-${i}:rgba(${textColor}, ${i / 10});`; 14 | } 15 | document.documentElement.style.cssText += cssText; 16 | } 17 | -------------------------------------------------------------------------------- /packages/utils/compressImage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 压缩图片 3 | * @param image 要压缩的图片 4 | * @param mimeType mime类型 5 | * @param quality 质量 6 | */ 7 | export default function compressImage( 8 | image: HTMLImageElement, 9 | mimeType: string, 10 | quality = 1, 11 | ): Promise { 12 | return new Promise((resolve) => { 13 | const canvas = document.createElement('canvas'); 14 | canvas.width = image.width; 15 | canvas.height = image.height; 16 | 17 | const ctx = canvas.getContext('2d'); 18 | if (ctx) { 19 | ctx.drawImage(image, 0, 0); 20 | canvas.toBlob(resolve, mimeType, quality); 21 | } else { 22 | resolve(null); 23 | } 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import Message from '../components/Message'; 2 | import socket from '../socket'; 3 | 4 | 5 | export default function fetch( 6 | event: string, 7 | data = {}, 8 | { toast = true } = {}, 9 | ): Promise<[string | null, T | null]> { 10 | return new Promise((resolve) => { 11 | socket.emit(event, data, (res: any) => { 12 | if (typeof res === 'string') { 13 | if (toast) { 14 | if (res !== '已过滤重复的消息') { 15 | Message.info(res); 16 | } 17 | } 18 | resolve([res, null]); 19 | } else { 20 | resolve([null, res]); 21 | } 22 | }); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatroom", 3 | "short_name": "chatroom", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#FFFFFF", 7 | "description": "AI聊天室", 8 | "orientation": "portrait-primary", 9 | "theme_color": "#4a90e2", 10 | "icons": [ 11 | { 12 | "src": "/favicon-96.png", 13 | "sizes": "96x96", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/favicon-192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/favicon-512.png", 23 | "sizes": "512x512", 24 | "type": "image/png" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/friend.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const FriendSchema = new Schema({ 4 | createTime: { type: Date, default: Date.now }, 5 | 6 | from: { 7 | type: Schema.Types.ObjectId, 8 | ref: 'User', 9 | index: true, 10 | }, 11 | to: { 12 | type: Schema.Types.ObjectId, 13 | ref: 'User', 14 | }, 15 | }); 16 | 17 | export interface FriendDocument extends Document { 18 | /** 源用户id */ 19 | from: string; 20 | /** 目标用户id */ 21 | to: string; 22 | /** 创建时间 */ 23 | createTime: Date; 24 | } 25 | 26 | /** 27 | * Friend Model 28 | * 好友信息 29 | * 好友关系是单向的 30 | */ 31 | const Friend = model('Friend', FriendSchema); 32 | 33 | export default Friend; 34 | -------------------------------------------------------------------------------- /packages/utils/test/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { addParam } from '../url'; 2 | 3 | describe('utils/url.ts', () => { 4 | it('should add ?key=value into url', () => { 5 | const url = 'https://bulita.suisuijiang.com'; 6 | const key = 'key'; 7 | const value = 'value'; 8 | const params = { 9 | [key]: value, 10 | }; 11 | expect(addParam(url, params)).toBe(`${url}?${key}=${value}`); 12 | }); 13 | 14 | it('should add &key=value into url', () => { 15 | const url = 'https://bulita.suisuijiang.com?a=a'; 16 | const key = 'key'; 17 | const value = 'value'; 18 | const params = { 19 | [key]: value, 20 | }; 21 | expect(addParam(url, params)).toBe(`${url}&${key}=${value}`); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/isLogin.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | export const PLEASE_LOGIN = '请登录后再试'; 4 | 5 | /** 6 | * 拦截未登录用户请求需要登录态的接口 7 | */ 8 | export default function isLogin(socket: Socket) { 9 | const noRequireLoginEvent = new Set([ 10 | 'register', 11 | 'login', 12 | 'loginByToken', 13 | 'guest', 14 | 'getDefaultGroupHistoryMessages', 15 | 'getDefaultGroupOnlineMembers', 16 | 'getBaiduToken', 17 | 'getGroupBasicInfo', 18 | 'getSTS', 19 | ]); 20 | return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 21 | if (!noRequireLoginEvent.has(event) && !socket.data.user) { 22 | // cb(PLEASE_LOGIN); 23 | } else { 24 | next(); 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/src/types/server.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Context { 2 | data: T; 3 | socket: { 4 | id: string; 5 | ip: string; 6 | user: string; 7 | isAdmin: boolean; 8 | join: (room: string) => void; 9 | leave: (room: string) => void; 10 | emit: (target: string[] | string, event: string, data: any) => void; 11 | }; 12 | } 13 | 14 | declare interface RouteHandler { 15 | (ctx: Context): string | any; 16 | } 17 | 18 | declare type Routes = Record; 19 | 20 | declare type MiddlewareArgs = Array; 21 | 22 | declare type MiddlewareNext = () => void; 23 | 24 | declare interface SendMessageData { 25 | /** 消息目标 */ 26 | to: string; 27 | /** 消息类型 */ 28 | type: string; 29 | /** 消息内容 */ 30 | content: string; 31 | } 32 | -------------------------------------------------------------------------------- /packages/web/src/components/Message.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .rc-notification { 3 | top: 5px !important; 4 | z-index: 1100 !important; 5 | } 6 | } 7 | 8 | .componentMessage { 9 | height: 100%; 10 | display: flex; 11 | align-items: center; 12 | margin-top: 1px; 13 | 14 | :global { 15 | .iconfont { 16 | font-size: 22px; 17 | 18 | &.icon-success { 19 | color: green; 20 | } 21 | &.icon-error { 22 | color: #d82e2e; 23 | } 24 | &.icon-warning { 25 | color: orange; 26 | } 27 | &.icon-info { 28 | color: #2773ef; 29 | } 30 | } 31 | } 32 | } 33 | 34 | .messageText { 35 | margin-left: 8px; 36 | font-size: 16px; 37 | } -------------------------------------------------------------------------------- /packages/database/mongoose/initMongoDB.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 连接 MongoDB 3 | */ 4 | 5 | import mongoose from 'mongoose'; 6 | 7 | import config from '@bulita/config/server'; 8 | import logger from '@bulita/utils/logger'; 9 | 10 | mongoose.Promise = Promise; 11 | mongoose.set('useCreateIndex', true); 12 | 13 | export default function initMongoDB() { 14 | return new Promise((resolve) => { 15 | mongoose.connect( 16 | config.database, 17 | { useNewUrlParser: true, useUnifiedTopology: true }, 18 | async (err) => { 19 | if (err) { 20 | logger.error('[mongoDB]', err.message); 21 | process.exit(0); 22 | } else { 23 | resolve(null); 24 | } 25 | }, 26 | ); 27 | }); 28 | } 29 | 30 | export { mongoose }; 31 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/seal.ts: -------------------------------------------------------------------------------- 1 | import { SEAL_TEXT } from '@bulita/utils/const'; 2 | import { getSocketIp } from '@bulita/utils/socket'; 3 | import { Socket } from 'socket.io'; 4 | import { 5 | getSealIpKey, 6 | getSealUserKey, 7 | Redis, 8 | } from '@bulita/database/redis/initRedis'; 9 | 10 | /** 11 | * 拦截被封禁用户的请求 12 | */ 13 | export default function seal(socket: Socket) { 14 | return async ([, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 15 | const ip = getSocketIp(socket); 16 | const isSealIp = await Redis.has(getSealIpKey(ip)); 17 | const isSealUser = 18 | socket.data.user && 19 | (await Redis.has(getSealUserKey(socket.data.user))); 20 | 21 | if (isSealUser || isSealIp) { 22 | cb(SEAL_TEXT); 23 | } else { 24 | next(); 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/config/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/ip@^1.1.0": 6 | version "1.1.0" 7 | resolved "https://registry.yarnpkg.com/@types/ip/-/ip-1.1.0.tgz#aec4f5bfd49e4a4c53b590d88c36eb078827a7c0" 8 | integrity sha512-dwNe8gOoF70VdL6WJBwVHtQmAX4RMd62M+mAB9HQFjG1/qiCLM/meRy95Pd14FYBbEDwCq7jgJs89cHpLBu4HQ== 9 | dependencies: 10 | "@types/node" "*" 11 | 12 | "@types/node@*": 13 | version "16.3.2" 14 | resolved "https://registry.yarnpkg.com/@types/node/-/node-16.3.2.tgz#655432817f83b51ac869c2d51dd8305fb8342e16" 15 | integrity sha512-jJs9ErFLP403I+hMLGnqDRWT0RYKSvArxuBVh2veudHV7ifEC1WAmjJADacZ7mRbA2nWgHtn8xyECMAot0SkAw== 16 | 17 | ip@^1.1.5: 18 | version "1.1.5" 19 | resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" 20 | integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= 21 | -------------------------------------------------------------------------------- /packages/utils/getRandomColor.ts: -------------------------------------------------------------------------------- 1 | import randomColor from 'randomcolor'; 2 | 3 | type ColorMode = 'dark' | 'bright' | 'light' | 'random'; 4 | 5 | /** 6 | * 获取随机颜色, 刷新页面不变 7 | * @param seed when passed will cause randomColor to return the same color each time 8 | */ 9 | export function getRandomColor(seed: string, luminosity: ColorMode = 'dark') { 10 | return randomColor({ 11 | luminosity, 12 | seed, 13 | }); 14 | } 15 | 16 | type Cache = { 17 | [key: string]: string; 18 | }; 19 | 20 | const cache: Cache = {}; 21 | 22 | /** 23 | * 获取随机颜色, 刷新页面后重新随机 24 | * @param seed 随机种子 25 | * @param luminosity 亮度 26 | */ 27 | export function getPerRandomColor( 28 | seed: string, 29 | luminosity: ColorMode = 'dark', 30 | ) { 31 | if (cache[seed]) { 32 | return cache[seed]; 33 | } 34 | cache[seed] = randomColor({ luminosity }); 35 | return cache[seed]; 36 | } 37 | -------------------------------------------------------------------------------- /packages/bin/scripts/getUserId.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import User from '@bulita/database/mongoose/models/user'; 3 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 4 | 5 | export async function getUserId(username: string) { 6 | if (!username) { 7 | console.log(chalk.red('Wrong command, [username] is missing.')); 8 | return; 9 | } 10 | 11 | await initMongoDB(); 12 | 13 | const user = await User.findOne({ username }); 14 | if (!user) { 15 | console.log(chalk.red(`User [${username}] does not exist`)); 16 | } else { 17 | console.log( 18 | `The userId of [${username}] is:`, 19 | chalk.green(user._id.toString()), 20 | ); 21 | } 22 | } 23 | 24 | async function run() { 25 | const username = process.argv[3]; 26 | await getUserId(username); 27 | process.exit(0); 28 | } 29 | export default run; 30 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/SelfInfo.less: -------------------------------------------------------------------------------- 1 | .selfInfo { 2 | height: initial !important; 3 | width: 500px; 4 | } 5 | 6 | .changeAvatar { 7 | position: relative; 8 | .blur { 9 | filter: blur(2px); 10 | } 11 | } 12 | 13 | .cropper { 14 | & > div { 15 | margin-bottom: 8px; 16 | } 17 | .loading { 18 | top: 170px; 19 | left: 170px; 20 | } 21 | } 22 | 23 | .preview { 24 | & > img { 25 | width: 100px; 26 | height: 100px; 27 | cursor: pointer; 28 | 29 | &:hover { 30 | filter: blur(3px); 31 | } 32 | } 33 | .loading { 34 | top: 10px; 35 | left: 10px; 36 | } 37 | } 38 | 39 | .loading { 40 | position: absolute; 41 | pointer-events: none; 42 | } 43 | 44 | .input { 45 | height: 36px; 46 | margin-bottom: 8px; 47 | } 48 | 49 | .button { 50 | width: 100px; 51 | height: 36px; 52 | } -------------------------------------------------------------------------------- /packages/config/client.ts: -------------------------------------------------------------------------------- 1 | import { MB } from '../utils/const'; 2 | 3 | export default { 4 | server: 5 | process.env.Server || 6 | (process.env.NODE_ENV === 'development' ? '//localhost:9200' : '/'), 7 | 8 | maxImageSize: process.env.MaxImageSize 9 | ? parseInt(process.env.MaxImageSize, 10) 10 | : MB * 1024, 11 | maxBackgroundImageSize: process.env.MaxBackgroundImageSize 12 | ? parseInt(process.env.MaxBackgroundImageSize, 10) 13 | : MB * 1024, 14 | maxAvatarSize: process.env.MaxAvatarSize 15 | ? parseInt(process.env.MaxAvatarSize, 10) 16 | : MB * 1024, 17 | maxFileSize: process.env.MaxFileSize 18 | ? parseInt(process.env.MaxFileSize, 10) 19 | : MB * 1024, 20 | 21 | defaultTheme: 'default', 22 | sound: process.env.SOUND, 23 | tagColorMode: 'singleColor', 24 | 25 | // 禁止用户撤回消息, 不包括管理员, 管理员始终能撤回任何消息 26 | disableDeleteMessage: false, 27 | }; 28 | -------------------------------------------------------------------------------- /packages/web/src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Style from './IconButton.less'; 4 | 5 | type Props = { 6 | width: number; 7 | height: number; 8 | icon: string; 9 | iconSize: number; 10 | className?: string; 11 | style?: Object; 12 | onClick?: () => void; 13 | }; 14 | 15 | function IconButton({ 16 | width, 17 | height, 18 | icon, 19 | iconSize, 20 | onClick = () => {}, 21 | className = '', 22 | style = {}, 23 | }: Props) { 24 | return ( 25 |
31 | 35 |
36 | ); 37 | } 38 | 39 | export default IconButton; 40 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/CodeEditor.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .codeEditor { 4 | width: 600px; 5 | 6 | @media @mobile { 7 | width: 100%; 8 | height: 410px; 9 | } 10 | 11 | :global { 12 | .rc-dialog-body { 13 | overflow-y: hidden; 14 | } 15 | } 16 | } 17 | 18 | .container { 19 | height: 55vh; 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | 24 | .selectContainer { 25 | display: flex; 26 | align-items: center; 27 | } 28 | 29 | .languageSelect { 30 | width: 150px; 31 | margin-left: 10px; 32 | } 33 | 34 | .title { 35 | font-size: 14px; 36 | font-weight: normal; 37 | color: #666; 38 | } 39 | 40 | .editorContainer { 41 | flex: 1; 42 | margin-top: 10px; 43 | border: 1px solid #f1f1f1; 44 | border-radius: 2px; 45 | } 46 | 47 | .sendButton { 48 | width: 100px; 49 | height: 32px; 50 | margin-top: 8px; 51 | align-self: flex-end; 52 | } -------------------------------------------------------------------------------- /packages/utils/expressions.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | default: [ 3 | '呵呵', 4 | '哈哈', 5 | '吐舌', 6 | '啊', 7 | '酷', 8 | '怒', 9 | '开心', 10 | '汗', 11 | '泪', 12 | '黑线', 13 | '鄙视', 14 | '不高兴', 15 | '真棒', 16 | '钱', 17 | '疑问', 18 | '阴险', 19 | '吐', 20 | '咦', 21 | '委屈', 22 | '花心', 23 | '呼', 24 | '笑眼', 25 | '冷', 26 | '太开心', 27 | '滑稽', 28 | '勉强', 29 | '狂汗', 30 | '乖', 31 | '睡觉', 32 | '惊哭', 33 | '升起', 34 | '惊讶', 35 | '喷', 36 | '爱心', 37 | '心碎', 38 | '玫瑰', 39 | '礼物', 40 | '星星月亮', 41 | '太阳', 42 | '音乐', 43 | '灯泡', 44 | '蛋糕', 45 | '彩虹', 46 | '钱币', 47 | '咖啡', 48 | 'haha', 49 | '胜利', 50 | '大拇指', 51 | '弱', 52 | 'ok', 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Common.less: -------------------------------------------------------------------------------- 1 | .container { 2 | 3 | } 4 | 5 | .block { 6 | margin-bottom: 14px; 7 | 8 | a, p, li { 9 | line-height: 22px; 10 | font-size: 14px; 11 | } 12 | ul { 13 | margin: 6px 0; 14 | padding-left: 26px; 15 | } 16 | } 17 | 18 | .title { 19 | line-height: 33px; 20 | font-size: 14px; 21 | color: #333; 22 | font-weight: bold; 23 | } 24 | 25 | .title2 { 26 | line-height: 33px; 27 | font-size: 14px; 28 | color: #333; 29 | } 30 | 31 | .href { 32 | color: #18a058; 33 | text-decoration: none; 34 | margin-left: 5px; 35 | } 36 | 37 | .block2 { 38 | margin-bottom: 14px; 39 | 40 | a, p, li { 41 | line-height: 22px; 42 | font-size: 14px; 43 | opacity: 0.75; 44 | } 45 | ul { 46 | margin: 6px 0; 47 | padding-left: 26px; 48 | } 49 | } 50 | 51 | .href2 { 52 | color: #000000; 53 | text-decoration: none; 54 | margin-left: 5px; 55 | } -------------------------------------------------------------------------------- /packages/utils/time.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | isToday(time1: Date, time2: Date) { 3 | return ( 4 | time1.getFullYear() === time2.getFullYear() && 5 | time1.getMonth() === time2.getMonth() && 6 | time1.getDate() === time2.getDate() 7 | ); 8 | }, 9 | isYesterday(time1: Date, time2: Date) { 10 | const prevDate = new Date(time1); 11 | prevDate.setDate(time1.getDate() - 1); 12 | return ( 13 | prevDate.getFullYear() === time2.getFullYear() && 14 | prevDate.getMonth() === time2.getMonth() && 15 | prevDate.getDate() === time2.getDate() 16 | ); 17 | }, 18 | getHourMinute(time: Date) { 19 | const hours = time.getHours(); 20 | const minutes = time.getMinutes(); 21 | return `${hours < 10 ? `0${hours}` : hours}:${ 22 | minutes < 10 ? `0${minutes}` : minutes 23 | }`; 24 | }, 25 | getMonthDate(time: Date) { 26 | return `${time.getMonth() + 1}/${time.getDate()}`; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/web/build/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | output: { 9 | publicPath: '/', 10 | }, 11 | devtool: 'inline-source-map', 12 | devServer: { 13 | hot: true, 14 | contentBase: ['./dist'], 15 | historyApiFallback: { 16 | rewrites: [{ from: /\/invite\/group\/[\w\d]+/, to: '/index.html' }], 17 | }, 18 | proxy: { 19 | '/avatar': 'http://localhost:9200', 20 | '/GroupAvatar': 'http://localhost:9200', 21 | '/Avatar': { 22 | target: 'http://localhost:9200', 23 | pathRewrite: { '^/Avatar': '/avatar' }, 24 | }, 25 | '/favicon-*.png': 'http://localhost:9200', 26 | }, 27 | }, 28 | plugins: [new ReactRefreshWebpackPlugin()], 29 | }); 30 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/CreateGroup.less: -------------------------------------------------------------------------------- 1 | .createGroup { 2 | 3 | } 4 | 5 | .container { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .text { 11 | font-size: 14px; 12 | font-weight:normal; 13 | line-height: 31px; 14 | color: #333; 15 | } 16 | 17 | .input { 18 | width: 100%; 19 | height: 40px; 20 | border-radius: 6px; 21 | border: 1px solid rgba(0, 0, 0, 0.2); 22 | //padding: 0 34px 0 8px; 23 | font-size: 14px; 24 | color: #333; 25 | box-sizing: border-box; 26 | -webkit-user-select: auto; 27 | -moz-user-select: auto; 28 | -ms-user-select: auto; 29 | user-select: auto; 30 | } 31 | 32 | .button { 33 | width: 66px; 34 | height: 36px; 35 | border-radius: 6px; 36 | margin-top: 12px; 37 | background-color: var(--primary-color-10); 38 | color: var(--primary-text-color-10); 39 | align-self: flex-end; 40 | font-size: 14px; 41 | border: none; 42 | 43 | &:hover { 44 | background-color: var(--primary-color-8); 45 | } 46 | } -------------------------------------------------------------------------------- /packages/web/test/localStorage.spec.ts: -------------------------------------------------------------------------------- 1 | import getData, { LocalStorageKey } from '../src/localStorage'; 2 | import config from '../../config/client'; 3 | import themes from '../src/themes'; 4 | 5 | describe('client/localStorage.ts', () => { 6 | it('should return localStorage value, or default value if not exists', () => { 7 | expect(getData().sound).toBe(config.sound); 8 | localStorage.setItem(LocalStorageKey.Sound, 'huaji'); 9 | expect(getData().sound).toBe('huaji'); 10 | }); 11 | 12 | it('should return default theme config when them not exists', () => { 13 | localStorage.setItem(LocalStorageKey.Theme, 'xxx'); 14 | expect(getData().primaryColor).toBe(themes.default.primaryColor); 15 | }); 16 | 17 | it('should return boolean type value when value is true / false', () => { 18 | localStorage.setItem(LocalStorageKey.SoundSwitch, 'true'); 19 | expect(getData().soundSwitch).toBe(true); 20 | localStorage.setItem(LocalStorageKey.SoundSwitch, 'false'); 21 | expect(getData().soundSwitch).toBe(false); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/isAdmin.ts: -------------------------------------------------------------------------------- 1 | import config from '@bulita/config/server'; 2 | import { Socket } from 'socket.io'; 3 | 4 | export const YOU_ARE_NOT_ADMINISTRATOR = '你不是管理员'; 5 | 6 | /** 7 | * 拦截非管理员用户请求需要管理员权限的接口 8 | */ 9 | export default function isAdmin(socket: Socket) { 10 | const requireAdminEvent = new Set([ 11 | 'sealUser', 12 | 'getSealList', 13 | 'resetUserPassword', 14 | 'setUserTag', 15 | 'getUserIps', 16 | 'sealIp', 17 | 'getSealIpList', 18 | 'toggleSendMessage', 19 | 'toggleNewUserSendMessage', 20 | 'getSystemConfig', 21 | ]); 22 | return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 23 | socket.data.isAdmin = 24 | !!socket.data.user && 25 | process.env.ADMINS_ARRAY.split(',').includes(socket.data.user); 26 | const isAdminEvent = requireAdminEvent.has(event); 27 | if (!socket.data.isAdmin && isAdminEvent) { 28 | cb(YOU_ARE_NOT_ADMINISTRATOR); 29 | } else { 30 | next(); 31 | } 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { css } from 'linaria'; 4 | 5 | const button = css` 6 | border: none; 7 | background-color: var(--primary-color-8_5); 8 | color: var(--primary-text-color-10); 9 | border-radius: 4px; 10 | font-size: 14px; 11 | transition: background-color 0.4s; 12 | user-select: none !important; 13 | 14 | &:hover { 15 | background-color: var(--primary-color-10); 16 | } 17 | `; 18 | 19 | type Props = { 20 | /** 类型: primary / danger */ 21 | type?: string; 22 | /** 按钮文本 */ 23 | children: string; 24 | className?: string; 25 | /** 点击事件 */ 26 | onClick?: () => void; 27 | }; 28 | 29 | function Button({ 30 | type = 'primary', 31 | children, 32 | className = '', 33 | onClick, 34 | }: Props) { 35 | return ( 36 | 43 | ); 44 | } 45 | 46 | export default Button; 47 | -------------------------------------------------------------------------------- /packages/server/src/routes/notification.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from 'assert'; 2 | import User from '@bulita/database/mongoose/models/user'; 3 | import Notification from '@bulita/database/mongoose/models/notification'; 4 | 5 | export async function setNotificationToken(ctx: Context<{ token: string }>) { 6 | const { token } = ctx.data; 7 | 8 | const user = await User.findOne({ _id: ctx.socket.user }); 9 | if (!user) { 10 | throw new AssertionError({ message: '用户不存在' }); 11 | } 12 | 13 | const notification = await Notification.findOne({ token: ctx.data.token }); 14 | if (notification) { 15 | notification.user = user; 16 | await notification.save(); 17 | } else { 18 | await Notification.create({ 19 | user, 20 | token, 21 | }); 22 | 23 | const existNotifications = await Notification.find({ user }); 24 | if (existNotifications.length > 3) { 25 | await Notification.deleteOne({ _id: existNotifications[0]._id }); 26 | } 27 | } 28 | 29 | return { 30 | isOK: true, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Chat.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .chat { 4 | flex: 1; 5 | display: flex; 6 | flex-direction: column; 7 | //background-color: rgba(241, 241, 241, 0.6); 3.13 ui 8 | background-color: white; 9 | border-top-right-radius: 10px; 10 | border-bottom-right-radius: 10px; 11 | overflow: hidden; 12 | position: relative; 13 | 14 | &[data-aero=true] { 15 | background-color: rgba(241, 241, 241, 0.15); 16 | } 17 | 18 | @media @mobile { 19 | border-top-right-radius: 0; 20 | border-bottom-right-radius: 0; 21 | } 22 | } 23 | 24 | .noLinkman { 25 | flex: 1; 26 | display: flex; 27 | align-items: center; 28 | justify-content: center; 29 | flex-direction: column; 30 | } 31 | 32 | .noLinkmanImage { 33 | border-radius: 8px; 34 | width: 170px; 35 | height: 180px; 36 | background-image: url('~@bulita/assets/images/no-linkman.jpeg'); 37 | background-position-y: 180px; 38 | } 39 | 40 | .noLinkmanText { 41 | margin-top: 16px; 42 | font-size: 14px; 43 | color: #666; 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2021 碎碎酱 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList.less: -------------------------------------------------------------------------------- 1 | @import "../../styles/variable.less"; 2 | 3 | .functionBarAndLinkmanList { 4 | width: 300px; 5 | height: 100%; 6 | position: relative; 7 | display: flex; 8 | background-color: unset; 9 | 10 | @media @mobile { 11 | position: absolute; 12 | left: 0; 13 | z-index: 1; 14 | width: 100%; 15 | background-color: rgba(37, 37, 37, 0.5); 16 | 17 | //.container { 18 | // background-color: var(--primary-color-7); 19 | //} 20 | } 21 | } 22 | 23 | .container { 24 | //background-color: rgb(247, 247, 247); 3.13 ui 25 | background-color: white; 26 | border: 1px solid rgba(208, 208, 208, 0.5); 27 | flex: 1; 28 | max-width: 300px; 29 | display: flex; 30 | flex-direction: column; 31 | 32 | &[data-aero=true] { 33 | background-color: var(--primary-color-0); 34 | } 35 | 36 | @media @mobile { 37 | //background-color: rgb(247, 247, 247); 3.13 ui 38 | background-color: white; 39 | border: 1px solid rgba(208, 208, 208, 0.5); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/bin/scripts/updateDefaultGroupName.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 3 | import Group from '@bulita/database/mongoose/models/group'; 4 | 5 | export async function updateDefaultGroupName(newName: string) { 6 | if (!newName) { 7 | console.log(chalk.red('Wrong command, [newName] is missing.')); 8 | return; 9 | } 10 | 11 | await initMongoDB(); 12 | 13 | const group = await Group.findOne({ isDefault: true }); 14 | if (!group) { 15 | console.log(chalk.red('Default group does not exist')); 16 | } else { 17 | group.name = newName; 18 | try { 19 | await group.save(); 20 | console.log(chalk.green('Update default group name success!')); 21 | } catch (err) { 22 | console.log( 23 | chalk.red('Update default group name fail!'), 24 | err.message, 25 | ); 26 | } 27 | } 28 | } 29 | 30 | async function run() { 31 | const newName = process.argv[3]; 32 | await updateDefaultGroupName(newName); 33 | process.exit(0); 34 | } 35 | export default run; 36 | -------------------------------------------------------------------------------- /packages/web/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Notification from 'rc-notification'; 3 | 4 | import 'rc-notification/dist/rc-notification.min.css'; 5 | import Style from './Message.less'; 6 | 7 | function showMessage(text: string, duration = 1500, type = 'success') { 8 | Notification.newInstance({}, (notification: any) => { 9 | notification.notice({ 10 | content: ( 11 |
12 | 13 | {text} 14 |
15 | ), 16 | duration, 17 | }); 18 | }); 19 | } 20 | 21 | export default { 22 | success(text: string, duration = 2.5) { 23 | showMessage(text, duration, 'success'); 24 | }, 25 | error(text: string, duration = 2.5) { 26 | showMessage(text, duration, 'error'); 27 | }, 28 | warning(text: string, duration = 2.5) { 29 | showMessage(text, duration, 'warning'); 30 | }, 31 | info(text: string, duration = 2.5) { 32 | showMessage(text, duration, 'info'); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/web/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AI聊天室 6 | 7 | 8 | <% if (process.env.NODE_ENV === 'production') { %> 9 | 10 | <% } %> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/socket.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const SocketSchema = new Schema({ 4 | createTime: { type: Date, default: Date.now }, 5 | 6 | id: { 7 | type: String, 8 | unique: true, 9 | index: true, 10 | }, 11 | user: { 12 | type: Schema.Types.ObjectId, 13 | ref: 'User', 14 | }, 15 | ip: String, 16 | os: { 17 | type: String, 18 | default: '', 19 | }, 20 | browser: { 21 | type: String, 22 | default: '', 23 | }, 24 | environment: { 25 | type: String, 26 | default: '', 27 | }, 28 | }); 29 | 30 | export interface SocketDocument extends Document { 31 | /** socket连接id */ 32 | id: string; 33 | /** 关联用户id */ 34 | user: any; 35 | /** ip地址 */ 36 | ip: string; 37 | /** 系统 */ 38 | os: string; 39 | /** 浏览器 */ 40 | browser: string; 41 | /** 详细环境信息 */ 42 | environment: string; 43 | /** 创建时间 */ 44 | createTime: Date; 45 | } 46 | 47 | /** 48 | * Socket Model 49 | * 客户端socket连接信息 50 | */ 51 | const Socket = model('Socket', SocketSchema); 52 | 53 | export default Socket; 54 | -------------------------------------------------------------------------------- /packages/web/src/components/Dialog.less: -------------------------------------------------------------------------------- 1 | @import '../styles/variable.less'; 2 | 3 | :global { 4 | .rc-dialog { 5 | width: 450px; 6 | top: 45% !important; 7 | transform: translateY(-50%) !important; 8 | } 9 | .rc-dialog-content { 10 | width: 100%; 11 | } 12 | .rc-dialog-title { 13 | font-size: 16px !important; 14 | } 15 | .rc-dialog-wrap { 16 | overflow: hidden !important; 17 | } 18 | .rc-dialog-close { 19 | top: 0 !important; 20 | right: 10px !important; 21 | z-index: 9999; 22 | 23 | .rc-dialog-close-x { 24 | font-size: 32px !important; 25 | line-height: 1.5; 26 | } 27 | } 28 | 29 | .rc-dialog-body { 30 | overflow-y: auto; 31 | -webkit-overflow-scrolling: touch; 32 | //max-height: 60vh; 33 | } 34 | 35 | @media @mobile { 36 | .rc-dialog { 37 | width: 94% !important; 38 | margin: 0 auto; 39 | } 40 | .rc-dialog-body { 41 | padding: 10px 10px; 42 | // max-height: 88%; 43 | } 44 | .rc-dialog-content { 45 | max-height: 80vh; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /packages/web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": "> 0.25%, not dead", 7 | "useBuiltIns": "entry", 8 | "corejs": 3, 9 | "modules": false 10 | } 11 | ], 12 | "@babel/preset-react", 13 | "linaria/babel" 14 | ], 15 | "plugins": [ 16 | [ 17 | "prismjs", 18 | { 19 | "languages": [ 20 | "clike", 21 | "javascript", 22 | "typescript", 23 | "java", 24 | "c", 25 | "cpp", 26 | "python", 27 | "ruby", 28 | "markup", 29 | "markup-templating", 30 | "php", 31 | "go", 32 | "csharp", 33 | "css", 34 | "sql", 35 | "json" 36 | ], 37 | "plugins": ["line-numbers", "copy-to-clipboard", "show-language"], 38 | "theme": "default", 39 | "css": true 40 | } 41 | ] 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/history.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const HistoryScheme = new Schema({ 4 | user: { 5 | type: String, 6 | required: true, 7 | }, 8 | linkman: { 9 | type: String, 10 | required: true, 11 | }, 12 | message: { 13 | type: String, 14 | required: true, 15 | }, 16 | }); 17 | 18 | export interface HistoryDocument extends Document { 19 | /** user id */ 20 | user: string; 21 | 22 | /** linkman id */ 23 | linkman: string; 24 | 25 | /** last readed message id */ 26 | message: string; 27 | } 28 | 29 | const History = model('History', HistoryScheme); 30 | 31 | export default History; 32 | 33 | export async function createOrUpdateHistory( 34 | userId: string, 35 | linkmanId: string, 36 | messageId: string, 37 | ) { 38 | const history = await History.findOne({ user: userId, linkman: linkmanId }); 39 | if (history) { 40 | history.message = messageId; 41 | await history.save(); 42 | } else { 43 | await History.create({ 44 | user: userId, 45 | linkman: linkmanId, 46 | message: messageId, 47 | }); 48 | } 49 | return {}; 50 | } 51 | -------------------------------------------------------------------------------- /packages/web/test/components/Button.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { render, screen, fireEvent } from '@testing-library/react'; 7 | import '@testing-library/jest-dom/extend-expect'; 8 | import Button from '../../src/components/Button'; 9 | 10 | describe('Button', () => { 11 | it('shoule render without error', async () => { 12 | render(); 13 | const $button = screen.getByRole('button'); 14 | expect($button).toBeInTheDocument(); 15 | }); 16 | 17 | it('shoule support set custom class name and type', async () => { 18 | render( 19 | , 22 | ); 23 | const $button = screen.getByRole('button'); 24 | expect($button.classList).toContain('custom'); 25 | expect($button.classList).toContain('danger'); 26 | }); 27 | 28 | it('shoule call props handler function when fire event', async () => { 29 | const handleClick = jest.fn(); 30 | render(); 31 | const $button = screen.getByRole('button'); 32 | fireEvent.click($button); 33 | expect(handleClick).toHaveBeenCalled(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 |
2 |

布里塔

3 | 4 | [English](./README.md) / 简体中文 5 | 6 | 布里塔是一个有趣的开源聊天室. 基于[node.js](https://nodejs.org/), [react](https://reactjs.org/) 和 [socket.io](https://socket.io/) 技术栈开发 7 | 8 | 官方网站: [https://chat.bulita.net/](https://chat.bulita.net) 9 | 10 | Github: [https://github.com/gochendong/bulita](https://github.com/gochendong/bulita) 11 |
12 | 13 | ## 特色 14 | 15 | 1. 前后端100%完全开源, 基于源码快速构建 16 | 2. 设置机器人自动回复, 为每个机器人设置单独的api, 并使用Markdown渲染机器人回复内容 17 | 3. 通过配置文件一键初始化群组, 联系人, bot等等 18 | 4. 你可以在官方网站上找到开发者, 及时解答你的疑问 19 | 20 | ## 安装 21 | 22 | 1. 切换到源码文件夹 23 | ``` 24 | git clone https://github.com/gochendong/bulita && cd bulita 25 | ``` 26 | 2. 复制 .env.example 到 .env 并编辑它 27 | 3. 运行Redis服务, 如果已经在运行了, 跳过这步 28 | ``` 29 | docker-compose -f docker-compose-redis.yaml up --build -d 30 | ``` 31 | 4. 运行MongoDB服务, 如果已经在运行了, 跳过这步 32 | ``` 33 | docker-compose -f docker-compose-mongo.yaml up --build -d 34 | ``` 35 | 5. 运行聊天室服务 36 | ``` 37 | docker-compose -f docker-compose.yaml up --build -d 38 | ``` 39 | 6. 现在你可以开始通过 http://localhost:9200 访问聊天室 40 | 41 | 42 | ## 参考项目 43 | 44 | [https://github.com/yinxin630/fiora](https://github.com/yinxin630/fiora) 45 | 46 | ## 开源协议 47 | 48 | 布里塔 is [MIT licensed](./LICENSE) 49 | 50 | ## 赞助本项目 51 | 52 | ![](https://docs.bulita.net/media/202412/usdt_1733018911.png) 53 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/group.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | import { NAME_REGEXP } from '@bulita/utils/const'; 3 | 4 | const GroupSchema = new Schema({ 5 | createTime: { type: Date, default: Date.now }, 6 | 7 | name: { 8 | type: String, 9 | trim: true, 10 | unique: true, 11 | match: NAME_REGEXP, 12 | index: true, 13 | }, 14 | avatar: String, 15 | announcement: { 16 | type: String, 17 | default: '', 18 | }, 19 | creator: { 20 | type: Schema.Types.ObjectId, 21 | ref: 'User', 22 | }, 23 | isDefault: { 24 | type: Boolean, 25 | default: false, 26 | }, 27 | members: [ 28 | { 29 | type: Schema.Types.ObjectId, 30 | ref: 'User', 31 | }, 32 | ], 33 | }); 34 | 35 | export interface GroupDocument extends Document { 36 | /** 群组名 */ 37 | name: string; 38 | /** 头像 */ 39 | avatar: string; 40 | /** 公告 */ 41 | announcement: string; 42 | /** 创建者 */ 43 | creator: string; 44 | /** 是否为默认群组 */ 45 | isDefault: boolean; 46 | /** 成员 */ 47 | members: string[]; 48 | /** 创建时间 */ 49 | createTime: Date; 50 | } 51 | 52 | /** 53 | * Group Model 54 | * 群组信息 55 | */ 56 | const Group = model('Group', GroupSchema); 57 | 58 | export default Group; 59 | -------------------------------------------------------------------------------- /packages/web/build/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin'); 4 | const WorkboxPlugin = require('workbox-webpack-plugin'); 5 | const WebpackBar = require('webpackbar'); 6 | const common = require('./webpack.common.js'); 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | output: { 11 | publicPath: process.env.PublicPath || '/', 12 | }, 13 | devtool: false, 14 | optimization: { 15 | minimize: true, 16 | minimizer: [ 17 | new TerserPlugin({ 18 | terserOptions: { 19 | format: { 20 | comments: false, 21 | }, 22 | }, 23 | extractComments: false, 24 | }), 25 | ], 26 | }, 27 | plugins: [ 28 | new ScriptExtHtmlPlugin({ 29 | custom: [ 30 | { 31 | test: /\.js$/, 32 | attribute: 'crossorigin', 33 | value: 'anonymous', 34 | }, 35 | ], 36 | }), 37 | new WorkboxPlugin.GenerateSW({ 38 | clientsClaim: true, 39 | skipWaiting: true, 40 | }), 41 | new WebpackBar(), 42 | ], 43 | }); 44 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/CodeDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Prism from 'prismjs'; 3 | 4 | import xss from '@bulita/utils/xss'; 5 | import Style from './CodeMessage.less'; 6 | import Dialog from '../../../components/Dialog'; 7 | 8 | interface CodeDialogProps { 9 | visible: boolean; 10 | onClose: () => void; 11 | language: string; 12 | code: string; 13 | } 14 | 15 | function CodeDialog(props: CodeDialogProps) { 16 | const { visible, onClose, language, code } = props; 17 | const html = 18 | language === 'text' 19 | ? xss(code) 20 | : // @ts-ignore 21 | Prism.highlight(code, Prism.languages[language]); 22 | setTimeout(Prism.highlightAll.bind(Prism), 0); // TODO: https://github.com/PrismJS/prism/issues/1487 23 | 24 | return ( 25 | 31 |
32 |                 
37 |             
38 |
39 | ); 40 | } 41 | 42 | export default CodeDialog; 43 | -------------------------------------------------------------------------------- /packages/server/src/routes/history.ts: -------------------------------------------------------------------------------- 1 | import { isValidObjectId, Types } from '@bulita/database/mongoose'; 2 | import assert from 'assert'; 3 | import User from '@bulita/database/mongoose/models/user'; 4 | import Group from '@bulita/database/mongoose/models/group'; 5 | import Message from '@bulita/database/mongoose/models/message'; 6 | import { createOrUpdateHistory } from '@bulita/database/mongoose/models/history'; 7 | 8 | export async function updateHistory( 9 | ctx: Context<{ userId: string; linkmanId: string; messageId: string }>, 10 | ) { 11 | const { linkmanId, messageId } = ctx.data; 12 | const self = ctx.socket.user.toString(); 13 | if (!Types.ObjectId.isValid(messageId)) { 14 | return { 15 | msg: `not update with invalid messageId:${messageId}`, 16 | }; 17 | } 18 | 19 | // @ts-ignore 20 | const [user, linkman, message] = await Promise.all([ 21 | User.findOne({ _id: self }), 22 | isValidObjectId(linkmanId) 23 | ? Group.findOne({ _id: linkmanId }) 24 | : User.findOne({ _id: linkmanId.replace(self, '') }), 25 | Message.findOne({ _id: messageId }), 26 | ]); 27 | assert(user, '用户不存在'); 28 | assert(linkman, '联系人不存在'); 29 | // assert(message, '消息不存在'); 30 | assert(message, '消息已被自动清理 请刷新'); 31 | 32 | await createOrUpdateHistory(self, linkmanId, messageId); 33 | 34 | return { 35 | msg: 'ok', 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /packages/server/test/middlewares/isAdmin.spec.ts: -------------------------------------------------------------------------------- 1 | import { mocked } from 'ts-jest/utils'; 2 | import config from '@bulita/config/server'; 3 | import { Socket } from 'socket.io'; 4 | import isAdmin, { 5 | YOU_ARE_NOT_ADMINISTRATOR, 6 | } from '../../src/middlewares/isAdmin'; 7 | import { getMiddlewareParams } from '../helpers/middleware'; 8 | 9 | jest.mock('@bulita/config/server'); 10 | 11 | describe('server/middlewares/isAdmin', () => { 12 | it('should call service fail when user not administrator', async () => { 13 | const socket = { 14 | id: 'id', 15 | data: { 16 | user: 'user', 17 | }, 18 | } as Socket; 19 | const middleware = isAdmin(socket); 20 | 21 | const { args, cb, next } = getMiddlewareParams('sealUser'); 22 | 23 | await middleware(args, next); 24 | // expect(cb).toBeCalledWith(YOU_ARE_NOT_ADMINISTRATOR); 25 | }); 26 | 27 | it('should call service success when user is administrator', async () => { 28 | mocked(config).administrator = ['administrator']; 29 | const socket = { 30 | id: 'id', 31 | data: { 32 | user: 'administrator', 33 | }, 34 | } as Socket; 35 | const middleware = isAdmin(socket); 36 | 37 | const { args, next } = getMiddlewareParams('sealUser'); 38 | 39 | await middleware(args, next); 40 | expect(next).toBeCalled(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/server", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=production DOTENV_CONFIG_PATH=../../.env ts-node -r dotenv/config --transpile-only src/main.ts", 8 | "dev:server": "cross-env NODE_ENV=development DOTENV_CONFIG_PATH=../../.env nodemon src/main.ts --exec \"ts-node --files -r dotenv/config\" --config .nodemonrc --watch ../" 9 | }, 10 | "dependencies": { 11 | "@bulita/bin": "^1.0.0", 12 | "@bulita/config": "^1.0.0", 13 | "@bulita/database": "^1.0.0", 14 | "@bulita/utils": "^1.0.0", 15 | "ali-oss": "^6.16.0", 16 | "axios": "^0.21.1", 17 | "bcryptjs": "^2.4.3", 18 | "expo-server-sdk": "^3.6.0", 19 | "jwt-simple": "^0.5.6", 20 | "koa": "^2.13.1", 21 | "koa-bodyparser": "^4.4.1", 22 | "koa-router": "^10.0.0", 23 | "koa-send": "^5.0.1", 24 | "koa-static": "^5.0.0", 25 | "regex-escape": "^3.4.10", 26 | "socket.io": "^4.1.3", 27 | "string-hash": "^1.1.3", 28 | "ts-jest": "^27.0.3" 29 | }, 30 | "devDependencies": { 31 | "@types/ali-oss": "^6.0.10", 32 | "@types/bcryptjs": "^2.4.2", 33 | "@types/koa": "^2.13.4", 34 | "@types/koa-router": "^7.4.4", 35 | "@types/koa-send": "^4.1.3", 36 | "@types/koa-static": "^4.0.2", 37 | "@types/string-hash": "^1.1.1", 38 | "cross-env": "^7.0.3", 39 | "dotenv": "^10.0.0", 40 | "nodemon": "^2.0.12" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/web/src/modules/InfoDialog.less: -------------------------------------------------------------------------------- 1 | @import '../styles/variable.less'; 2 | 3 | .infoDialog { 4 | width: 300px !important; 5 | @media @mobile { 6 | width: 80% !important; 7 | } 8 | 9 | :global { 10 | .rc-dialog-body { 11 | padding: 0; 12 | & > div { 13 | width: 100%; 14 | } 15 | } 16 | } 17 | } 18 | 19 | .coantainer { 20 | text-align: center; 21 | } 22 | 23 | .header { 24 | background-color: rgb(240, 242, 245); 25 | padding: 20px 0 14px 0; 26 | border-top-left-radius: 6px; 27 | border-top-right-radius: 6px; 28 | } 29 | 30 | .ip { 31 | span { 32 | font-size: 13px; 33 | color: #888; 34 | margin: 0 2px; 35 | cursor: pointer; 36 | } 37 | } 38 | 39 | .largeAvatar { 40 | position: absolute; 41 | top: -100px; 42 | left: 220px; 43 | width: 300px; 44 | height: 300px; 45 | z-index: 9999; 46 | 47 | @media @mobile { 48 | width: 180px; 49 | height: 180px; 50 | left: 60px; 51 | top: -165px; 52 | } 53 | } 54 | 55 | .info { 56 | padding: 10px 20px 20px 20px; 57 | & > button { 58 | width: 100%; 59 | height: 34px; 60 | margin-top: 10px; 61 | } 62 | } 63 | 64 | .onlineStatus { 65 | text-align: left; 66 | display: flex; 67 | height: 30px; 68 | align-items: center; 69 | font-size: 14px; 70 | } 71 | 72 | .onlineText { 73 | width: 50px; 74 | color: #666; 75 | } 76 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import useIsLogin from '../../hooks/useIsLogin'; 5 | import useAction from '../../hooks/useAction'; 6 | import FunctionBar from './FunctionBar'; 7 | import LinkmanList from './LinkmanList'; 8 | 9 | import Style from './FunctionBarAndLinkmanList.less'; 10 | import { State } from '../../state/reducer'; 11 | import useAero from '../../hooks/useAero'; 12 | 13 | function FunctionBarAndLinkmanList() { 14 | const isLogin = useIsLogin(); 15 | const action = useAction(); 16 | const functionBarAndLinkmanListVisible = useSelector( 17 | (state: State) => state.status.functionBarAndLinkmanListVisible, 18 | ); 19 | const aero = useAero(); 20 | 21 | if (!functionBarAndLinkmanListVisible) { 22 | return null; 23 | } 24 | 25 | function handleClick(e: any) { 26 | if (e.target === e.currentTarget) { 27 | action.setStatus('functionBarAndLinkmanListVisible', false); 28 | } 29 | } 30 | 31 | return ( 32 |
37 |
38 | {isLogin && } 39 | 40 |
41 |
42 | ); 43 | } 44 | 45 | export default FunctionBarAndLinkmanList; 46 | -------------------------------------------------------------------------------- /packages/config/server.ts: -------------------------------------------------------------------------------- 1 | import ip from 'ip'; 2 | 3 | const { env } = process; 4 | 5 | export default { 6 | /** 服务端host, 默认为本机ip地址(可能会是局域网地址) */ 7 | host: env.HOST || ip.address(), 8 | 9 | // service port 10 | port: env.PORT ? parseInt(env.Port, 10) : 9200, 11 | 12 | // mongodb address 13 | database: `mongodb://${env.MONGODB_HOST}:${env.MONGODB_PORT}/bulita`, 14 | 15 | redis: { 16 | host: env.REDIS_HOST || ip.address(), 17 | port: env.REDIS_PORT ? parseInt(env.REDIS_PORT, 10) : 6379, 18 | }, 19 | 20 | // jwt encryption secret 21 | jwtSecret: env.JWT_SECRET, 22 | 23 | // Maximize the number of groups 24 | maxGroupsCount: env.MAX_GROUP_NUM ? parseInt(env.MAX_GROUP_NUM, 10) : 0, 25 | 26 | allowOrigin: env.AllowOrigin ? env.AllowOrigin.split(',') : null, 27 | 28 | // token expires time 29 | tokenExpiresTime: env.TOKEN_EXPIRES_TIME 30 | ? parseInt(env.TOKEN_EXPIRES_TIME, 10) * 1000 31 | : 7 * 1000 * 60 * 60 * 24, 32 | 33 | administrators: env.ADMINS ? env.ADMINS?.split(',') : [], 34 | 35 | /** 禁用注册功能 */ 36 | disableRegister: false, 37 | 38 | /** Aliyun OSS */ 39 | aliyunOSS: { 40 | enable: env.ALIYUN_OSS ? env.ALIYUN_OSS === 'true' : false, 41 | accessKeyId: env.ACCESS_KEY_ID || '', 42 | accessKeySecret: env.ACCESS_KEY_SECRET || '', 43 | roleArn: env.ROLE_ARN || '', 44 | region: env.REGION || '', 45 | bucket: env.BUCKET || '', 46 | endpoint: env.ENDPOINT || '', 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Sidebar.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .sidebar { 4 | width: 60px; 5 | min-width: min-content; 6 | //background-color: rgb(234, 228, 223); 3.13 ui 7 | background-color: white; 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | position: relative; 12 | border-top-left-radius: 10px; 13 | border-bottom-left-radius: 10px; 14 | 15 | &[data-aero=true] { 16 | background-color: var(--primary-color-0); 17 | } 18 | 19 | @media @mobile { 20 | width: 60px; 21 | padding: 0 3px; 22 | border-top-left-radius: 0; 23 | border-bottom-left-radius: 0; 24 | } 25 | } 26 | 27 | .avatar { 28 | margin-top: 50px; 29 | cursor: pointer; 30 | } 31 | 32 | .status { 33 | position: absolute; 34 | top: 88px; 35 | left: 42px; 36 | } 37 | 38 | .tabs { 39 | margin-top: 50px; 40 | } 41 | 42 | .buttons { 43 | position: absolute; 44 | bottom: 40px; 45 | display: flex; 46 | flex-direction: column; 47 | align-items: center; 48 | 49 | :global { 50 | .iconfont { 51 | color: var(--primary-text-color-7); 52 | transition: color 0.2s; 53 | } 54 | } 55 | 56 | & > div, .linkButton { 57 | margin-top: 10px; 58 | &:hover { 59 | .iconfont { 60 | color: var(--primary-text-color-10); 61 | } 62 | } 63 | } 64 | } 65 | 66 | .linkButton { 67 | text-decoration: none; 68 | } 69 | -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/LoginAndRegister.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { 5 | Tabs, 6 | TabPane, 7 | TabContent, 8 | ScrollableInkTabBar, 9 | } from '../../components/Tabs'; 10 | import Style from './LoginAndRegister.less'; 11 | import Login from './Login'; 12 | import Register from './Register'; 13 | import Dialog from '../../components/Dialog'; 14 | import { State } from '../../state/reducer'; 15 | import useAction from '../../hooks/useAction'; 16 | 17 | function LoginAndRegister() { 18 | const action = useAction(); 19 | const loginRegisterDialogVisible = useSelector( 20 | (state: State) => state.status.loginRegisterDialogVisible, 21 | ); 22 | 23 | return ( 24 | action.toggleLoginRegisterDialog(false)} 28 | > 29 | } 33 | renderTabContent={() => } 34 | > 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default LoginAndRegister; 47 | -------------------------------------------------------------------------------- /packages/bin/scripts/deleteTodayRegisteredUsers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Delete users created today and their related data 3 | */ 4 | import chalk from 'chalk'; 5 | 6 | import inquirer from 'inquirer'; 7 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 8 | import User from '@bulita/database/mongoose/models/user'; 9 | import { deleteUser } from './deleteUser'; 10 | 11 | export async function deleteTodayRegisteredUsers() { 12 | await initMongoDB(); 13 | 14 | const now = new Date(); 15 | const time = new Date( 16 | `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} 00:00:00`, 17 | ); 18 | const users = await User.find({ 19 | createTime: { 20 | $gte: time, 21 | }, 22 | }); 23 | console.log( 24 | `There are ${chalk.green( 25 | users.length.toString(), 26 | )} newly registered users today`, 27 | ); 28 | if (users.length === 0) { 29 | return; 30 | } 31 | 32 | const shouldDeleteUsers = await inquirer.prompt({ 33 | type: 'confirm', 34 | name: 'result', 35 | message: 'Confirm to delete these users?', 36 | }); 37 | if (!shouldDeleteUsers.result) { 38 | return; 39 | } 40 | await Promise.all( 41 | users.map((user) => deleteUser(user._id.toString(), false)), 42 | ); 43 | 44 | console.log( 45 | chalk.green('Successfully deleted today’s newly registered users'), 46 | ); 47 | } 48 | 49 | async function run() { 50 | await deleteTodayRegisteredUsers(); 51 | process.exit(0); 52 | } 53 | export default run; 54 | -------------------------------------------------------------------------------- /packages/web/src/utils/playSound.ts: -------------------------------------------------------------------------------- 1 | import DefaultSound from '@bulita/assets/audios/default.mp3'; 2 | import AppleSound from '@bulita/assets/audios/apple.mp3'; 3 | import PcQQSound from '@bulita/assets/audios/pcqq.mp3'; 4 | import MobileQQSound from '@bulita/assets/audios/mobileqq.mp3'; 5 | import MoMoSound from '@bulita/assets/audios/momo.mp3'; 6 | import HuaJiSound from '@bulita/assets/audios/huaji.mp3'; 7 | 8 | type Sounds = { 9 | [key: string]: string; 10 | }; 11 | 12 | const sounds: Sounds = { 13 | default: DefaultSound, 14 | apple: AppleSound, 15 | pcqq: PcQQSound, 16 | mobileqq: MobileQQSound, 17 | momo: MoMoSound, 18 | huaji: HuaJiSound, 19 | }; 20 | 21 | let prevType = 'default'; 22 | const $audio = document.createElement('audio'); 23 | const $source = document.createElement('source'); 24 | $audio.volume = 0.6; 25 | $source.setAttribute('type', 'audio/mp3'); 26 | $source.setAttribute('src', sounds[prevType]); 27 | $audio.appendChild($source); 28 | document.body.appendChild($audio); 29 | 30 | let isPlaying = false; 31 | 32 | async function play() { 33 | if (!isPlaying) { 34 | isPlaying = true; 35 | 36 | try { 37 | await $audio.play(); 38 | } catch (err) { 39 | console.warn('播放新消息提示音失败', err.message); 40 | } finally { 41 | isPlaying = false; 42 | } 43 | } 44 | } 45 | 46 | export default function playSound(type = 'default') { 47 | if (type !== prevType) { 48 | $source.setAttribute('src', sounds[type]); 49 | $audio.load(); 50 | prevType = type; 51 | } 52 | play(); 53 | } 54 | -------------------------------------------------------------------------------- /packages/server/test/middlewares/isLogin.spec.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import isLogin, { PLEASE_LOGIN } from '../../src/middlewares/isLogin'; 3 | import { getMiddlewareParams } from '../helpers/middleware'; 4 | 5 | describe('server/middlewares/isLogin', () => { 6 | it('should call service fail when user not login', async () => { 7 | const socket = { 8 | id: 'id', 9 | data: {}, 10 | } as Socket; 11 | const middleware = isLogin(socket); 12 | 13 | const { args, cb, next } = getMiddlewareParams('sendMessage'); 14 | 15 | await middleware(args, next); 16 | // expect(cb).toBeCalledWith(PLEASE_LOGIN); 17 | }); 18 | 19 | it('should call service success when user is login', async () => { 20 | const socket = { 21 | id: 'id', 22 | data: { 23 | user: 'user', 24 | }, 25 | } as Socket; 26 | const middleware = isLogin(socket); 27 | 28 | const { args, next } = getMiddlewareParams('sendMessage'); 29 | 30 | await middleware(args, next); 31 | expect(next).toBeCalled(); 32 | }); 33 | 34 | it('should call service success when it not need login ', async () => { 35 | const socket = { 36 | id: 'id', 37 | data: { 38 | user: 'user', 39 | }, 40 | } as Socket; 41 | const middleware = isLogin(socket); 42 | 43 | const { args, next } = getMiddlewareParams('register'); 44 | 45 | await middleware(args, next); 46 | expect(next).toBeCalled(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/InviteMessageV2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Style from './InviteMessage.less'; 4 | import { joinGroup, getLinkmanHistoryMessages } from '../../../service'; 5 | import useAction from '../../../hooks/useAction'; 6 | import Message from '../../../components/Message'; 7 | 8 | interface InviteMessageProps { 9 | inviteInfo: string; 10 | } 11 | 12 | function InviteMessage(props: InviteMessageProps) { 13 | const { inviteInfo } = props; 14 | const invite = JSON.parse(inviteInfo); 15 | 16 | const action = useAction(); 17 | 18 | async function handleJoinGroup() { 19 | const group = await joinGroup(invite.group); 20 | if (group) { 21 | group.type = 'group'; 22 | action.addLinkman(group, true); 23 | Message.success('加入群组成功'); 24 | const messages = await getLinkmanHistoryMessages(invite.group, 0); 25 | if (messages) { 26 | action.addLinkmanHistoryMessages(invite.group, messages); 27 | } 28 | } 29 | } 30 | 31 | return ( 32 |
37 |
38 | 39 | "{invite.inviterName}" 邀请你加入群组「 40 | {invite.groupName}」 41 | 42 |
43 |

加入

44 |
45 | ); 46 | } 47 | 48 | export default InviteMessage; 49 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/Linkman.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .linkman { 4 | height: 90px; 5 | display: flex; 6 | align-items: center; 7 | padding: 10px 16px; 8 | cursor: default; 9 | transition: background-color 0.2s; 10 | } 11 | 12 | .focus { 13 | background-color: rgb(222, 222, 222); 14 | 15 | &[data-aero=true] { 16 | background-color: rgba(255, 255, 255, 0.15); 17 | } 18 | 19 | @media @mobile { 20 | background-color: rgb(222, 222, 222); 21 | } 22 | } 23 | 24 | .container { 25 | flex: 1; 26 | margin-left: 12px; 27 | } 28 | 29 | .rowContainer { 30 | display: flex; 31 | justify-content: space-between; 32 | } 33 | 34 | .nameTimeBlock { 35 | margin-top: 4px; 36 | } 37 | 38 | .name { 39 | color: var(--primary-text-color-10); 40 | font-size: 14px; 41 | } 42 | 43 | .time { 44 | color: var(--primary-text-color-5); 45 | font-size: 12px; 46 | position: relative; 47 | top: 4px; 48 | } 49 | 50 | .previewUnreadBlock { 51 | margin-top: 6px; 52 | } 53 | 54 | .preview { 55 | color: var(--primary-text-color-5); 56 | font-size: 12px; 57 | width: 188px; 58 | height: 20px; 59 | line-height: 20px; 60 | overflow: hidden; 61 | white-space: nowrap; 62 | text-overflow: ellipsis; 63 | } 64 | 65 | .unread { 66 | @size: 15px; 67 | background-color: var(--primary-color-10); 68 | width: @size; 69 | height: @size; 70 | border-radius: 50%; 71 | text-align: center; 72 | & > span { 73 | color: #e9e9e9; 74 | font-size: 10px; 75 | line-height: @size; 76 | position: relative; 77 | top: -3px; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/web/src/styles/normalize.less: -------------------------------------------------------------------------------- 1 | @import '~normalize.css/normalize.css'; 2 | 3 | @font-face { 4 | font-family: 'local-file'; 5 | src: url('~@bulita/assets/fonts/font.woff'); 6 | } 7 | 8 | :global { 9 | * { 10 | //user-select: none; 11 | } 12 | 13 | html, body, #app { 14 | width: 100%; 15 | height: 100%; 16 | //overflow: hidden; 17 | } 18 | 19 | html { 20 | //font-family: 'local-file', 'Arial', 'Verdana', '微软雅黑', '黑体', 'serif'; 21 | font-family: 'Arial', 'Verdana', '微软雅黑', '黑体', 'serif'; 22 | } 23 | 24 | p, h1, h2, h3, h4 { 25 | margin: 0; 26 | } 27 | 28 | div { 29 | box-sizing: border-box; 30 | } 31 | 32 | button, input, textarea { 33 | outline: none; 34 | } 35 | 36 | button { 37 | cursor: pointer; 38 | } 39 | 40 | div, p, span { 41 | color: #333; 42 | } 43 | 44 | body {overflow-y:hidden;} 45 | body:hover {overflow-y:scroll;} 46 | 47 | ::-webkit-scrollbar { 48 | display: none; 49 | } 50 | .show-scrollbar::-webkit-scrollbar { 51 | display: block; 52 | width: 6px; 53 | height: 6px; 54 | } 55 | ::-webkit-scrollbar-thumb { 56 | background-color: rgba(0, 0, 0, 0.2); 57 | } 58 | ::-webkit-scrollbar-track { 59 | background: rgba(255, 255, 255, 0.1); 60 | } 61 | 62 | .online { 63 | background-color: rgba(94, 212, 92, 1); 64 | } 65 | .offline { 66 | background-color: rgba(206, 12, 35, 1); 67 | } 68 | 69 | .show { 70 | display: block; 71 | } 72 | .hide { 73 | display: none !important; 74 | } 75 | } -------------------------------------------------------------------------------- /packages/server/test/middlewares/seal.spec.ts: -------------------------------------------------------------------------------- 1 | import { mocked } from 'ts-jest/utils'; 2 | import { SEAL_TEXT } from '@bulita/utils/const'; 3 | import { Socket } from 'socket.io'; 4 | import { Redis } from '@bulita/database/redis/initRedis'; 5 | import seal from '../../src/middlewares/seal'; 6 | import { getMiddlewareParams } from '../helpers/middleware'; 7 | 8 | jest.mock('@bulita/database/redis/initRedis'); 9 | 10 | describe('server/middlewares/seal', () => { 11 | it('should call service success', async () => { 12 | const socket = { 13 | id: 'id', 14 | data: { 15 | user: 'user', 16 | }, 17 | handshake: { 18 | headers: { 19 | 'x-real-ip': '127.0.0.1', 20 | }, 21 | }, 22 | } as unknown as Socket; 23 | const middleware = seal(socket); 24 | 25 | const { args, next } = getMiddlewareParams(); 26 | 27 | await middleware(args, next); 28 | expect(next).toBeCalled(); 29 | }); 30 | 31 | it('should call service fail when user has been sealed', async () => { 32 | mocked(Redis.has).mockReturnValue(Promise.resolve(true)); 33 | const socket = { 34 | id: 'id', 35 | data: { 36 | user: 'user', 37 | }, 38 | handshake: { 39 | headers: { 40 | 'x-real-ip': '127.0.0.1', 41 | }, 42 | }, 43 | } as unknown as Socket; 44 | const middleware = seal(socket); 45 | 46 | const { args, cb, next } = getMiddlewareParams(); 47 | 48 | await middleware(args, next); 49 | expect(cb).toBeCalledWith(SEAL_TEXT); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/bin/scripts/fixUsersAvatar.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import inquirer from 'inquirer'; 3 | import User from '@bulita/database/mongoose/models/user'; 4 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 5 | 6 | export async function fixUsersAvatar( 7 | searchValue: string, 8 | replaceValue: string, 9 | ) { 10 | searchValue = searchValue || 'bulitaavatar'; 11 | replaceValue = replaceValue || 'bulita/avatar'; 12 | 13 | await initMongoDB(); 14 | 15 | const users = await User.find({ avatar: { $regex: 'bulitaavatar' } }); 16 | if (users.length) { 17 | console.log(chalk.red('Oh No!'), "Some user's avatar is wrong"); 18 | users.forEach((user) => { 19 | console.log(user._id, user.username, user.avatar); 20 | }); 21 | 22 | const shouldFix = await inquirer.prompt({ 23 | type: 'confirm', 24 | name: 'result', 25 | message: 'Confirm to fix?', 26 | }); 27 | if (shouldFix.result) { 28 | await Promise.all( 29 | users.map((user) => { 30 | user.avatar = user.avatar.replace( 31 | searchValue, 32 | replaceValue, 33 | ); 34 | return user.save(); 35 | }), 36 | ); 37 | console.log(chalk.green('Congratulations! Fixed!')); 38 | } 39 | } else { 40 | console.log(chalk.green('OK!'), "All user's avatar is corrent"); 41 | } 42 | } 43 | 44 | async function run() { 45 | const searchValue = process.argv[3]; 46 | const replaceValue = process.argv[4]; 47 | await fixUsersAvatar(searchValue, replaceValue); 48 | process.exit(0); 49 | } 50 | export default run; 51 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/HeaderBar.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .headerBar { 4 | height: 70px; 5 | border-bottom: 1px solid rgba(208, 208, 208, 0.6); 6 | display: flex; 7 | align-items: center; 8 | padding: 0px 18px; 9 | justify-content: space-between; 10 | position: relative; 11 | 12 | &[data-aero=true] { 13 | border-bottom: 1px solid rgba(208, 208, 208, 0.3); 14 | } 15 | 16 | @media @mobile { 17 | height: 50px; 18 | padding: 0 6px; 19 | } 20 | 21 | :global { 22 | .iconfont { 23 | color: var(--primary-color-10); 24 | &:hover { 25 | color: var(--primary-color-8); 26 | } 27 | } 28 | 29 | .online, .offline { 30 | display: inline-block; 31 | width: 10px; 32 | height: 10px; 33 | border-radius: 50%; 34 | margin-right: 4px; 35 | transform: translateY(1px); 36 | } 37 | } 38 | } 39 | 40 | .buttonContainer { 41 | display: flex; 42 | width: 80px; 43 | } 44 | 45 | .rightButtonContainer { 46 | justify-content: flex-end; 47 | } 48 | 49 | .name { 50 | font-size: 16px; 51 | color: #333; 52 | 53 | @media @mobile { 54 | font-size: 14px; 55 | height: 100%; 56 | display: flex; 57 | flex-direction: column; 58 | align-items: center; 59 | justify-content: center; 60 | flex: 1; 61 | transform: translateY(2px); 62 | } 63 | } 64 | 65 | .status { 66 | color: #999; 67 | font-size: 15px; 68 | transform: scale(0.6); 69 | } 70 | 71 | .tag { 72 | color: #09bc8c; 73 | font-size: 12px; 74 | transform: scale(0.6); 75 | margin-left: 3px; 76 | } 77 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/CreateGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import Style from './CreateGroup.less'; 4 | import Dialog from '../../components/Dialog'; 5 | import Input from '../../components/Input'; 6 | import Message from '../../components/Message'; 7 | import { createGroup } from '../../service'; 8 | import useAction from '../../hooks/useAction'; 9 | 10 | interface CreateGroupProps { 11 | visible: boolean; 12 | onClose: () => void; 13 | } 14 | 15 | function CreateGroup(props: CreateGroupProps) { 16 | const { visible, onClose } = props; 17 | const action = useAction(); 18 | const [groupName, setGroupName] = useState(''); 19 | 20 | async function handleCreateGroup() { 21 | const group = await createGroup(groupName); 22 | if (group) { 23 | group.type = 'group'; 24 | action.addLinkman(group, true); 25 | setGroupName(''); 26 | onClose(); 27 | Message.success('创建群组成功'); 28 | } 29 | } 30 | 31 | return ( 32 | 33 |
34 |

请输入群组名

35 | 40 | 47 |
48 |
49 | ); 50 | } 51 | 52 | export default CreateGroup; 53 | -------------------------------------------------------------------------------- /packages/web/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent, useState, useMemo } from 'react'; 2 | import { getOSSFileUrl } from '../utils/uploadFile'; 3 | 4 | export const avatarFailback = '/avatar/0.jpg'; 5 | 6 | type Props = { 7 | /** 头像链接 */ 8 | src: string; 9 | /** 展示大小 */ 10 | size?: number; 11 | /** 额外类名 */ 12 | className?: string; 13 | /** 点击事件 */ 14 | onClick?: () => void; 15 | onMouseEnter?: () => void; 16 | onMouseLeave?: () => void; 17 | }; 18 | 19 | function Avatar({ 20 | src, 21 | size = 50, 22 | className = '', 23 | onClick, 24 | onMouseEnter, 25 | onMouseLeave, 26 | }: Props) { 27 | const [failTimes, updateFailTimes] = useState(0); 28 | 29 | /** 30 | * Handle avatar load fail event. Use faillback avatar instead 31 | * If still fail then ignore error event 32 | */ 33 | function handleError(e: SyntheticEvent) { 34 | if (failTimes >= 2) { 35 | return; 36 | } 37 | e.currentTarget.src = avatarFailback; 38 | updateFailTimes(failTimes + 1); 39 | } 40 | 41 | const url = useMemo(() => { 42 | if (/^(blob|data):/.test(src)) { 43 | return src; 44 | } 45 | return getOSSFileUrl( 46 | src, 47 | `image/resize,w_${size * 2},h_${size * 2}/quality,q_90`, 48 | ); 49 | }, [src]); 50 | 51 | return ( 52 | 62 | ); 63 | } 64 | 65 | export default Avatar; 66 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 必填 redis 和 mongoDB 2 | # 一般是局域网IP 3 | REDIS_HOST=127.0.0.1 4 | MONGODB_HOST=127.0.0.1 5 | 6 | # 你应该自己修改的配置 7 | # JWT密钥, 为了安全,你必须修改下面的字符串 8 | JWT_SECRET=ysehh4qM3ZuNzJHArC2XvB99jfOV6lMY 9 | # 默认的管理员和bot密码, 也是管理员重置用户密码后的默认密码 10 | DEFAULT_PASSWORD=bulita 11 | # 管理员名字, 多个用逗号隔开, 第一个管理员将成为默认群组的创建者 12 | ADMINS=布里塔 13 | # 默认群组名字 14 | DEFAULT_GROUP_NAME=AI聊天室 15 | # 默认网站title名字 16 | DEFAULT_TITLE='AI聊天室' 17 | # 机器人列表, 多个用逗号隔开, 每个机器人都必须有自己的_API配置 18 | BOTS=AI 19 | # 默认群组中自动回复的机器人名字 20 | DEFAULT_BOT_NAME=AI 21 | # 默认自动添加的联系人列表 22 | DEFAULT_LINKMANS=AI,布里塔 23 | # AI机器人接口(命名规则统一: 机器人名字_API) 这个接口是作者提供仅供体验, 请创建一个自己的BOT服务 24 | # 所有机器人统一入参 POST JSON {"prompt": 内容, "group": mongoDB id, "uid": 雪花id} 25 | AI_API=https://bulita.net/api/v2/ 26 | # 允许用户创建的最大群组数量, 管理员不受限制 27 | MAX_GROUP_NUM=0 28 | # 是否只允许搜索默认群组 29 | ONLY_SEARCH_DEFAULT_GROUP=true 30 | # 私聊消息通知回调域名 31 | PRIVATE_MSG_CALLBACK_DOMAIN=https://chat.bulita.net 32 | 33 | # 这些配置你可以不用修改 34 | # JWT Token有效期, 单位:秒 35 | TOKEN_EXPIRES_TIME=2592000 36 | # redis端口号 37 | REDIS_PORT=6379 38 | # mongoDB端口号 39 | MONGODB_PORT=27017 40 | # 游客注册后IP使用间隔时间, 单位:秒 41 | REGISTER_IP_INTERVAL=60 42 | # 默认游客的用户名规则 43 | DEFAULT_USERNAME=新手用户 44 | # 默认提示音 45 | #[apple pcqq mobileqq momo huaji] 46 | SOUND='apple' 47 | # 获取IP定位接口 GET 接口自动与IP进行拼接, http://xxx/ip/ 48 | IP_LOCATION_API= 49 | # 禁止在默认群组发言的IP地区, 与上面IP接口的返回值对应 50 | BANED_IP_LOCS= 51 | # 配合IP禁止, 是否只禁止未设置密码的用户 52 | BANED_ONLY_UNSET_PASSWORD=true 53 | # 收到私聊时lpush到redis的键值 54 | NOTIFY_KEY= 55 | # 用户每分钟发言最大次数 56 | MAX_CALL_PER_MINUTES=8 57 | # 24小时内的新用户每分钟发言最大次数 58 | NEW_USER_MAX_CALL_PER_MINUTES=5 59 | # 禁言后自动解除的持续时间, 单位:秒 60 | LIFT_BAN_DURATION=10 61 | # 封禁IP持续时间, 单位:秒 62 | SEAL_IP_DURATION=86400 63 | # 封禁用户持续时间, 单位:秒 64 | SEAL_USER_DURATION=86400 65 | # 密码规则, 正则表达式 66 | PASSWORD_REGEX='^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$' 67 | # 密码规则提示文本 68 | PASSWORD_TIPS='需要包含至少一个小写字母、一个大写字母、一个数字,并且长度至少为8个字符' 69 | # 是否允许新用户注册 70 | ENABLE_REGISTER_USER=true -------------------------------------------------------------------------------- /packages/database/mongoose/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | import { NAME_REGEXP } from '@bulita/utils/const'; 3 | 4 | const UserSchema = new Schema({ 5 | id: { 6 | type: Number, 7 | index: true, 8 | }, 9 | createTime: { type: Date, default: Date.now }, 10 | lastLoginTime: { type: Date, default: Date.now }, 11 | 12 | username: { 13 | type: String, 14 | trim: true, 15 | unique: true, 16 | match: NAME_REGEXP, 17 | index: true, 18 | }, 19 | salt: String, 20 | email: { 21 | type: String, 22 | }, 23 | level: Number, 24 | signature: String, 25 | pushToken: String, 26 | password: { 27 | type: String, 28 | trim: true, 29 | }, 30 | avatar: String, 31 | tag: { 32 | type: String, 33 | default: '', 34 | trim: true, 35 | match: NAME_REGEXP, 36 | }, 37 | expressions: [ 38 | { 39 | type: String, 40 | }, 41 | ], 42 | lastLoginIp: String, 43 | }); 44 | 45 | export interface UserDocument extends Document { 46 | /** 用户id */ 47 | id: Number; 48 | /** 用户名 */ 49 | username: string; 50 | /** 邮箱 */ 51 | email: string; 52 | /** 等级 */ 53 | level: Number; 54 | /** 签名 */ 55 | signature: string; 56 | /** 推送token */ 57 | pushToken: string; 58 | /** 密码加密盐 */ 59 | salt: string; 60 | /** 加密的密码 */ 61 | password: string; 62 | /** 头像 */ 63 | avatar: string; 64 | /** 用户标签 */ 65 | tag: string; 66 | /** 表情收藏 */ 67 | expressions: string[]; 68 | /** 创建时间 */ 69 | createTime: Date; 70 | /** 最后登录时间 */ 71 | lastLoginTime: Date; 72 | /** 最后登录IP */ 73 | lastLoginIp: string; 74 | } 75 | 76 | /** 77 | * User Model 78 | * 用户信息 79 | */ 80 | const User = model('User', UserSchema); 81 | 82 | export default User; 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Bulita

3 | 4 | English / [简体中文](./README_ZH.md) 5 | 6 | Bulita is an interesting open source chatroom. It is developed based on [node.js](https://nodejs.org/), [react](https://reactjs.org/) and [socket.io](https://socket.io/) technologies 7 | 8 | Online Example: [https://chat.bulita.net/](https://chat.bulita.net) 9 | 10 | Github: [https://github.com/gochendong/bulita](https://github.com/gochendong/bulita) 11 |
12 | 13 | ## Features 14 | 15 | 1. 100% fully open source frontend and backend, allowing for rapid construction based on the source code. 16 | 2. Set up automatic replies for robots, set up separate APIs for each robot, and use Markdown to render the content of robot replies. 17 | 3. One-click initialization of groups, contacts, bots, etc. through configuration files. 18 | 4. You can find developers on the official website who can answer your questions in a timely manner. 19 | 20 | ## Install 21 | 22 | 1. Switch to the code folder 23 | ``` 24 | git clone https://github.com/gochendong/bulita && cd bulita 25 | ``` 26 | 2. copy the .env.example to .env and edit it 27 | 3. Start the Redis service. If it is already running, skip this step 28 | ``` 29 | docker-compose -f docker-compose-redis.yaml up --build -d 30 | ``` 31 | 4. Start the MongoDB service. If it is already running, skip this step 32 | ``` 33 | docker-compose -f docker-compose-mongo.yaml up --build -d 34 | ``` 35 | 5. Start the chatroom service 36 | ``` 37 | docker-compose -f docker-compose.yaml up --build -d 38 | ``` 39 | 6. Now you can access the chatroom through http://localhost:9200 40 | 41 | 42 | ## Referenced project 43 | 44 | [https://github.com/yinxin630/fiora](https://github.com/yinxin630/fiora) 45 | 46 | ## License 47 | 48 | bulita is [MIT licensed](./LICENSE) 49 | 50 | ## Sponsor this project 51 | 52 | ![](https://docs.bulita.net/media/202412/usdt_1733018911.png) 53 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Download.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import QRCode from 'qrcode.react'; 3 | 4 | import Dialog from '../../components/Dialog'; 5 | import Style from './Download.less'; 6 | import Common from './Common.less'; 7 | 8 | interface DownloadProps { 9 | visible: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | function Download(props: DownloadProps) { 14 | const { visible, onClose } = props; 15 | const androidDownloadUrl = `${window.location.origin}/bulita.apk`; 16 | const iOSDownloadUrl = 'https://apps.apple.com/cn/app/bulita/id1554719127'; 17 | 18 | return ( 19 | 25 |
26 |
27 |

Android

28 |
29 |

30 | 链接:{' '} 31 | 32 | {androidDownloadUrl} 33 | 34 |

35 | 36 |
37 |
38 |
39 |

iOS

40 |
41 |

42 | 链接: {iOSDownloadUrl} 43 |

44 | 45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | 52 | export default Download; 53 | -------------------------------------------------------------------------------- /packages/bin/scripts/doctor.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import cp from 'child_process'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import detect from 'detect-port'; 6 | import server from '@bulita/config/server'; 7 | import initRedis from '@bulita/database/redis/initRedis'; 8 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 9 | 10 | export async function doctor() { 11 | console.log(chalk.yellow('===== Run bulita Doctor =====')); 12 | 13 | const nodeVersion = cp.execSync('node --version').toString(); 14 | console.log( 15 | chalk.green(`node ${nodeVersion.slice(0, nodeVersion.length - 1)}`), 16 | ); 17 | 18 | await initMongoDB(); 19 | console.log(chalk.green('MongoDB is OK')); 20 | 21 | await (async () => 22 | new Promise((resolve) => { 23 | const redis = initRedis(); 24 | redis.on('connect', resolve); 25 | }))(); 26 | console.log(chalk.green('Redis is OK')); 27 | 28 | const avaliablePort = await detect(server.port); 29 | if (avaliablePort === server.port) { 30 | console.log(chalk.green(`Port [${server.port}] is OK`)); 31 | } else { 32 | console.log(chalk.red(`Port [${server.port}] was occupied`)); 33 | } 34 | 35 | const indexFilePath = path.resolve( 36 | __dirname, 37 | '../../server/public/index.html', 38 | ); 39 | const indexFile = fs.readFileSync(indexFilePath); 40 | if (!indexFile) { 41 | console.log(chalk.red('Homepage not exists')); 42 | } else if (indexFile.toString().includes('默认首页')) { 43 | console.log( 44 | chalk.red( 45 | 'Homepage is default. Please build web client by [yarn build:web]', 46 | ), 47 | ); 48 | } else { 49 | console.log(chalk.green(`Homepage is OK`)); 50 | } 51 | } 52 | 53 | async function run() { 54 | await doctor(); 55 | process.exit(0); 56 | } 57 | export default run; 58 | -------------------------------------------------------------------------------- /packages/web/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'opn'; 2 | declare module 'webpack'; 3 | declare module 'http-proxy-middleware'; 4 | declare module 'webpack-dev-middleware'; 5 | declare module 'webpack-hot-middleware'; 6 | declare module 'connect-history-api-fallback'; 7 | declare module 'ora'; 8 | declare module 'rimraf'; 9 | declare module 'less-plugin-autoprefix'; 10 | declare module 'extract-text-webpack-plugin'; 11 | declare module 'webpack-merge'; 12 | declare module 'html-webpack-plugin'; 13 | declare module 'friendly-errors-webpack-plugin'; 14 | declare module 'webpack-dashboard/plugin'; 15 | declare module 'copy-webpack-plugin'; 16 | declare module 'optimize-css-assets-webpack-plugin'; 17 | declare module 'script-ext-html-webpack-plugin'; 18 | declare module 'webpack-bundle-analyzer'; 19 | declare module 'react-radio-buttons'; 20 | declare module 'rc-tabs'; 21 | declare module 'rc-tabs/lib/TabContent'; 22 | declare module 'rc-tabs/lib/ScrollableInkTabBar'; 23 | declare module 'rc-menu'; 24 | declare module 'rc-dropdown'; 25 | declare module 'rc-notification'; 26 | declare module 'brace/mode/javascript'; 27 | declare module 'brace/mode/typescript'; 28 | declare module 'brace/mode/java'; 29 | declare module 'brace/mode/c_cpp'; 30 | declare module 'brace/mode/python'; 31 | declare module 'brace/mode/ruby'; 32 | declare module 'brace/mode/php'; 33 | declare module 'brace/mode/golang'; 34 | declare module 'brace/mode/csharp'; 35 | declare module 'brace/mode/html'; 36 | declare module 'brace/mode/css'; 37 | declare module 'brace/mode/markdown'; 38 | declare module 'brace/mode/sql'; 39 | declare module 'brace/mode/json'; 40 | 41 | declare module '*.less'; 42 | declare module '*.json'; 43 | declare module '*.png'; 44 | declare module '*.jpg'; 45 | declare module '*.jpeg'; 46 | declare module '*.gif'; 47 | declare module '*.mp3'; 48 | 49 | declare var __TEST__: false; 50 | 51 | declare interface Window { 52 | Notification: any; 53 | __REDUX_DEVTOOLS_EXTENSION__: any; 54 | } 55 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/FileMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'linaria'; 3 | import filesize from 'filesize'; 4 | import { getOSSFileUrl } from '../../../utils/uploadFile'; 5 | 6 | const styles = { 7 | container: css` 8 | display: block; 9 | min-width: 160px; 10 | max-width: 240px; 11 | padding: 0 4px; 12 | text-align: center; 13 | cursor: pointer; 14 | color: var(--primary-text-color-10); 15 | text-decoration: none; 16 | `, 17 | fileInfo: css` 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | border-bottom: 1px solid #eee; 23 | `, 24 | fileInfoText: css` 25 | word-break: break-all; 26 | `, 27 | button: css` 28 | display: inline-block; 29 | font-size: 12px; 30 | text-align: center; 31 | margin-top: 6px; 32 | `, 33 | }; 34 | 35 | type Props = { 36 | file: string; 37 | percent: number; 38 | }; 39 | 40 | function FileMessage({ file, percent }: Props) { 41 | const { fileUrl, filename, size } = JSON.parse(file); 42 | const url = fileUrl && getOSSFileUrl(fileUrl); 43 | 44 | return ( 45 | 51 |
52 | {filename} 53 | {filesize(size)} 54 |
55 |

56 | {percent === undefined || percent >= 100 57 | ? '下载' 58 | : // : `上传中... ${percent.toFixed(0)}%`} 59 | `上传中...`} 60 |

61 |
62 | ); 63 | } 64 | 65 | export default React.memo(FileMessage); 66 | -------------------------------------------------------------------------------- /packages/utils/convertMessage.ts: -------------------------------------------------------------------------------- 1 | import WuZeiNiangImage from '@bulita/assets/images/wuzeiniang.gif'; 2 | 3 | // function convertRobot10Message(message) { 4 | // if (message.from._id === '5adad39555703565e7903f79') { 5 | // try { 6 | // const parseMessage = JSON.parse(message.content); 7 | // message.from.tag = parseMessage.source; 8 | // message.from.avatar = parseMessage.avatar; 9 | // message.from.username = parseMessage.username; 10 | // message.type = parseMessage.type; 11 | // message.content = parseMessage.content; 12 | // } catch (err) { 13 | // console.warn('解析robot10消息失败', err); 14 | // } 15 | // } 16 | // } 17 | 18 | function convertSystemMessage(message: any) { 19 | if (message.type === 'system') { 20 | message.from._id = 'system'; 21 | message.from.originUsername = message.from.username; 22 | message.from.username = '系统'; 23 | message.from.avatar = WuZeiNiangImage; 24 | message.from.tag = 'system'; 25 | 26 | const content = JSON.parse(message.content); 27 | switch (content.command) { 28 | case 'roll': { 29 | message.content = `掷出了${content.value}点 (上限${content.top}点)`; 30 | break; 31 | } 32 | case 'rps': { 33 | message.content = `使出了 ${content.value}`; 34 | break; 35 | } 36 | default: { 37 | message.content = '不支持的指令'; 38 | } 39 | } 40 | } else if (message.deleted) { 41 | message.type = 'system'; 42 | message.from._id = 'system'; 43 | message.from.originUsername = message.from.username; 44 | message.from.username = '系统'; 45 | message.from.avatar = WuZeiNiangImage; 46 | message.from.tag = 'system'; 47 | message.content = `撤回了消息`; 48 | } 49 | } 50 | 51 | export default function convertMessage(message: any) { 52 | convertSystemMessage(message); 53 | return message; 54 | } 55 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/About.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Dialog from '../../components/Dialog'; 4 | import Style from './About.less'; 5 | import Common from './Common.less'; 6 | 7 | interface AboutProps { 8 | visible: boolean; 9 | onClose: () => void; 10 | } 11 | 12 | function About(props: AboutProps) { 13 | const { visible, onClose } = props; 14 | return ( 15 | 21 |
22 | 34 | 44 |
45 |

46 | Powered by 47 | 53 | bulita 54 | 55 |

56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | export default About; 63 | -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/LoginRegister.less: -------------------------------------------------------------------------------- 1 | .loginRegister { 2 | height: 260px; 3 | display: flex; 4 | flex-direction: column; 5 | padding: 0 30px; 6 | } 7 | 8 | .title { 9 | font-size: 14px; 10 | font-weight: normal; 11 | line-height: 27px; 12 | margin-top: 20px; 13 | color: #666; 14 | } 15 | 16 | .input { 17 | height: 40px; 18 | & > input { 19 | line-height: 40px; 20 | } 21 | } 22 | 23 | .loginButton { 24 | background-color: var(--primary-color-10); 25 | color: var(--primary-text-color-10); 26 | height: 40px; 27 | border: none; 28 | margin-top: 30px; 29 | transition: background-color 0.2s; 30 | border-radius: 6px; 31 | 32 | &:hover { 33 | background-color: var(--primary-color-9); 34 | } 35 | } 36 | 37 | .button { 38 | margin-top: 80px; 39 | height: 70px; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | gap: 10px; 44 | padding: 0px 15px; 45 | background-color: red; 46 | border-radius: 10px; 47 | border: none; 48 | color: white; 49 | position: relative; 50 | cursor: pointer; 51 | font-weight: 900; 52 | transition-duration: .2s; 53 | background: linear-gradient(0deg, #000, #272727); 54 | } 55 | 56 | .button:before, .button:after { 57 | content: ''; 58 | position: absolute; 59 | left: -2px; 60 | top: -2px; 61 | border-radius: 10px; 62 | background: linear-gradient(45deg, #fb0094, #0000ff, #00ff00,#ffff00, #ff0000, #fb0094, 63 | #0000ff, #00ff00,#ffff00, #ff0000); 64 | background-size: 400%; 65 | width: calc(100% + 4px); 66 | height: calc(100% + 4px); 67 | z-index: -1; 68 | animation: steam 20s linear infinite; 69 | } 70 | 71 | @keyframes steam { 72 | 0% { 73 | background-position: 0 0; 74 | } 75 | 76 | 50% { 77 | background-position: 400% 0; 78 | } 79 | 80 | 100% { 81 | background-position: 0 0; 82 | } 83 | } 84 | 85 | .button:after { 86 | filter: blur(50px); 87 | } 88 | 89 | 90 | 91 | .href { 92 | color: #18a058; 93 | text-decoration: none; 94 | } -------------------------------------------------------------------------------- /packages/bin/scripts/deleteMessages.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import inquirer from 'inquirer'; 4 | import { promisify } from 'util'; 5 | import chalk from 'chalk'; 6 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 7 | import Message from '@bulita/database/mongoose/models/message'; 8 | import History from '@bulita/database/mongoose/models/history'; 9 | 10 | export async function deleteMessages() { 11 | const shouldDeleteAllMessages = await inquirer.prompt({ 12 | type: 'confirm', 13 | name: 'result', 14 | message: 'Confirm to delete all messages?', 15 | }); 16 | if (!shouldDeleteAllMessages.result) { 17 | return; 18 | } 19 | 20 | await initMongoDB(); 21 | 22 | const deleteResult = await Message.deleteMany({}); 23 | console.log('Delete result:', deleteResult); 24 | 25 | const deleteHistoryResult = await History.deleteMany({}); 26 | console.log('Delete history result:', deleteHistoryResult); 27 | 28 | const shouldDeleteAllFiles = await inquirer.prompt({ 29 | type: 'confirm', 30 | name: 'result', 31 | message: 'Confirm to delete all message files(Except OSS files)?', 32 | }); 33 | if (!shouldDeleteAllFiles.result) { 34 | return; 35 | } 36 | 37 | const files = await promisify(fs.readdir)( 38 | path.resolve(__dirname, '../../server/public/'), 39 | ); 40 | const iamgesAndFiles = files.filter( 41 | (filename) => 42 | filename.startsWith('ImageMessage_') || 43 | filename.startsWith('FileMessage_'), 44 | ); 45 | const unlinkAsync = promisify(fs.unlink); 46 | await Promise.all( 47 | iamgesAndFiles.map((file) => 48 | unlinkAsync(path.resolve(__dirname, '../../server/public/', file)), 49 | ), 50 | ); 51 | console.log('Delete files:', chalk.green(iamgesAndFiles.length.toString())); 52 | console.log(chalk.red(iamgesAndFiles.join('\n'))); 53 | 54 | console.log(chalk.green('Successfully deleted all messages')); 55 | } 56 | 57 | async function run() { 58 | await deleteMessages(); 59 | process.exit(0); 60 | } 61 | export default run; 62 | -------------------------------------------------------------------------------- /packages/web/src/utils/voice.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import fetch from './fetch'; 3 | 4 | const $audio = document.createElement('audio'); 5 | const $source = document.createElement('source'); 6 | $audio.volume = 0.6; 7 | $source.setAttribute('type', 'audio/mp3'); 8 | $source.setAttribute('src', ''); 9 | $audio.appendChild($source); 10 | document.body.appendChild($audio); 11 | 12 | let baiduToken = ''; 13 | async function read(text: string, cuid: string) { 14 | if (!baiduToken) { 15 | const [err, result] = await fetch('getBaiduToken'); 16 | if (err) { 17 | return; 18 | } 19 | baiduToken = result.token; 20 | } 21 | 22 | const res = await axios.get( 23 | `https://tsn.baidu.com/text2audio?tex=${text}&tok=${baiduToken}&cuid=${cuid}&ctp=1&lan=zh&per=4`, 24 | { responseType: 'blob' }, 25 | ); 26 | const blob = res.data; 27 | if (res.status !== 200 || blob.type === 'application/json') { 28 | console.warn('合成语言失败'); 29 | } else { 30 | $source.setAttribute('src', URL.createObjectURL(blob)); 31 | $audio.load(); 32 | 33 | try { 34 | const playEndPromise = new Promise((resolve) => { 35 | $audio.onended = resolve; 36 | }); 37 | await $audio.play(); 38 | // eslint-disable-next-line consistent-return 39 | return playEndPromise; 40 | } catch (err) { 41 | console.warn('语言朗读消息失败', err.message); 42 | } 43 | } 44 | } 45 | 46 | type Task = { 47 | text: string; 48 | cuid: string; 49 | }; 50 | 51 | const taskQueue: Task[] = []; 52 | let isWorking = false; 53 | async function handleTaskQueue() { 54 | isWorking = true; 55 | const task = taskQueue.shift(); 56 | if (task) { 57 | await read(task.text, task.cuid); 58 | await handleTaskQueue(); 59 | } else { 60 | isWorking = false; 61 | } 62 | } 63 | 64 | const voice = { 65 | push(text: string, cuid: string) { 66 | taskQueue.push({ text, cuid }); 67 | if (!isWorking) { 68 | handleTaskQueue(); 69 | } 70 | }, 71 | }; 72 | 73 | export default voice; 74 | -------------------------------------------------------------------------------- /packages/web/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | 3 | import IconButton from './IconButton'; 4 | import Style from './Input.less'; 5 | 6 | interface InputProps { 7 | value?: string; 8 | type?: string; 9 | placeholder?: string; 10 | className?: string; 11 | autoComplete?: string; 12 | showClearBtn?: boolean; 13 | onChange: (value: string) => void; 14 | onEnter?: (value: string) => void; 15 | onFocus?: () => void; 16 | } 17 | 18 | function Input(props: InputProps) { 19 | const { 20 | value, 21 | type = 'text', 22 | placeholder = '', 23 | className = '', 24 | autoComplete = 'off', 25 | showClearBtn = true, 26 | onChange, 27 | onEnter = () => {}, 28 | onFocus = () => {}, 29 | } = props; 30 | 31 | const $input = useRef(null); 32 | 33 | function handleInput(e: any) { 34 | onChange(e.target.value); 35 | } 36 | 37 | function handleKeyDown(e: any) { 38 | if (e.key === 'Enter') { 39 | onEnter(value as string); 40 | } 41 | } 42 | 43 | function handleClickClear() { 44 | onChange(''); 45 | // @ts-ignore 46 | $input.current.focus(); 47 | } 48 | 49 | return ( 50 |
51 | 62 | {value && showClearBtn && ( 63 | 71 | )} 72 |
73 | ); 74 | } 75 | 76 | export default Input; 77 | -------------------------------------------------------------------------------- /packages/web/test/components/Avatar.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { render, screen, fireEvent } from '@testing-library/react'; 7 | import '@testing-library/jest-dom/extend-expect'; 8 | import Avatar, { avatarFailback } from '../../src/components/Avatar'; 9 | 10 | describe('Avatar', () => { 11 | it('shoule render without error', async () => { 12 | render(); 13 | const $img = await screen.findByRole('img'); 14 | expect($img).toBeInTheDocument(); 15 | }); 16 | 17 | it('should call props handler function when fire event', async () => { 18 | const handleClick = jest.fn(); 19 | const handleMouseEnter = jest.fn(); 20 | const handleMouseLeave = jest.fn(); 21 | render( 22 | , 28 | ); 29 | const $img = await screen.findByRole('img'); 30 | fireEvent.click($img); 31 | expect(handleClick).toBeCalled(); 32 | fireEvent.mouseEnter($img); 33 | expect(handleMouseEnter).toBeCalled(); 34 | fireEvent.mouseLeave($img); 35 | expect(handleMouseLeave).toBeCalled(); 36 | }); 37 | 38 | it('shoule use failback avatar when fetch error', async () => { 39 | const src = 'origin.jpg'; 40 | render(); 41 | const $img = (await screen.findByRole('img')) as HTMLImageElement; 42 | expect($img.src).toEqual(expect.stringContaining(src)); 43 | fireEvent.error($img); 44 | expect($img.src).toEqual(expect.stringContaining(avatarFailback)); 45 | fireEvent.error($img); 46 | fireEvent.error($img); 47 | }); 48 | 49 | it('shoule not add CDN query params', async () => { 50 | const src = 'data:base64/png;xxx'; 51 | render(); 52 | const $img = (await screen.findByRole('img')) as HTMLImageElement; 53 | expect($img.src).not.toEqual(expect.stringContaining('x-oss-process=')); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/CodeMessage.less: -------------------------------------------------------------------------------- 1 | @import '../../../styles/variable.less'; 2 | 3 | .codeMessage { 4 | width: 160px; 5 | padding: 0 4px; 6 | text-align: center; 7 | cursor: pointer; 8 | color: var(--primary-text-color-10); 9 | } 10 | 11 | .codeInfo { 12 | display: flex; 13 | border-bottom: 1px solid #eee; 14 | align-items: center; 15 | justify-content: center; 16 | } 17 | 18 | .icon { 19 | width: 30px; 20 | height: 30px; 21 | text-align: center; 22 | line-height: 30px; 23 | margin: 0 2px; 24 | } 25 | 26 | .codeSize { 27 | margin-left: 8px; 28 | } 29 | 30 | .codeViewButton { 31 | display: inline-block; 32 | font-size: 12px; 33 | text-align: center; 34 | margin-top: 6px; 35 | } 36 | 37 | .codeDialog { 38 | width: 800px !important; 39 | max-height: 800px; 40 | display: flex; 41 | 42 | @media @mobile { 43 | max-width: 90vw; 44 | } 45 | 46 | :global { 47 | .rc-dialog-content { 48 | flex: 1; 49 | min-height: 300px; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | .rc-dialog-body { 54 | padding: 0 16px; 55 | overflow-y: auto; 56 | -webkit-overflow-scrolling: touch; 57 | max-width: 800px; 58 | position: relative; 59 | flex: 1; 60 | * { 61 | user-select: text; 62 | } 63 | @media @mobile { 64 | max-width: 100%; 65 | } 66 | } 67 | } 68 | } 69 | 70 | .codeDialogButton { 71 | height: 26px; 72 | position: fixed; 73 | top: 58px; 74 | right: 18px; 75 | z-index: 999; 76 | font-size: 13px; 77 | } 78 | 79 | .pre { 80 | margin: 0 !important; 81 | font-size: 12px !important; 82 | background-color: white !important; 83 | position: relative !important; 84 | 85 | :global { 86 | .line-numbers-rows { 87 | position: absolute; 88 | left: 0 !important; 89 | top: 12px !important; 90 | background-color: white; 91 | } 92 | } 93 | } 94 | 95 | .code { 96 | position: static !important; 97 | } -------------------------------------------------------------------------------- /packages/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import 'core-js/stable'; 3 | import 'regenerator-runtime/runtime'; 4 | 5 | import React from 'react'; 6 | import ReactDom from 'react-dom'; 7 | import { Provider } from 'react-redux'; 8 | 9 | import config from '@bulita/config/client'; 10 | import setCssVariable from './utils/setCssVariable'; 11 | import App from './App'; 12 | import store from './state/store'; 13 | import getData from './localStorage'; 14 | 15 | // 注册 Service Worker 16 | if (window.location.protocol === 'https:' && 'serviceWorker' in navigator) { 17 | window.addEventListener('load', () => { 18 | navigator.serviceWorker.register(`/service-worker.js`); 19 | }); 20 | } 21 | 22 | // 如果配置了前端监控, 动态加载并启动监控 23 | if (config.frontendMonitorAppId) { 24 | // @ts-ignore 25 | import(/* webpackChunkName: "frontend-monitor" */ 'wpk-reporter').then( 26 | (module) => { 27 | const WpkReporter = module.default; 28 | 29 | const __wpk = new WpkReporter({ 30 | bid: config.frontendMonitorAppId, 31 | spa: true, 32 | rel: config.frontendMonitorAppId, 33 | uid: () => localStorage.getItem('username') || '', 34 | plugins: [], 35 | }); 36 | 37 | __wpk.installAll(); 38 | }, 39 | ); 40 | } 41 | 42 | // 更新 css variable 43 | const { primaryColor, primaryTextColor } = getData(); 44 | setCssVariable(primaryColor, primaryTextColor); 45 | 46 | // 请求 Notification 授权 47 | if ( 48 | window.Notification && 49 | (window.Notification.permission === 'default' || 50 | window.Notification.permission === 'denied') 51 | ) { 52 | window.Notification.requestPermission(); 53 | } 54 | 55 | if (window.location.pathname !== '/') { 56 | const { pathname } = window.location; 57 | window.history.pushState({}, 'bulita', '/'); 58 | if (pathname.startsWith('/invite/group/')) { 59 | const groupId = pathname.replace(`/invite/group/`, ''); 60 | window.sessionStorage.setItem('inviteGroupId', groupId); 61 | } 62 | } 63 | 64 | ReactDom.render( 65 | 66 | 67 | , 68 | document.getElementById('app'), 69 | ); 70 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/LinkmanList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { Linkman, State } from '../../state/reducer'; 5 | import LinkmanComponent from './Linkman'; 6 | 7 | import Style from './LinkmanList.less'; 8 | 9 | function LinkmanList() { 10 | const linkmans = useSelector((state: State) => state.linkmans); 11 | 12 | function renderLinkman(linkman: Linkman) { 13 | const messages = Object.values(linkman.messages); 14 | const lastMessage = 15 | messages.length > 0 ? messages[messages.length - 1] : null; 16 | 17 | let time = new Date(linkman.createTime); 18 | let preview = '暂无消息'; 19 | if (lastMessage) { 20 | time = new Date(lastMessage.createTime); 21 | const { type } = lastMessage; 22 | preview = type === 'text' ? `${lastMessage.content}` : `[${type}]`; 23 | if (linkman.type === 'group') { 24 | preview = `${lastMessage.from.username}: ${preview}`; 25 | } 26 | } 27 | return ( 28 | 38 | ); 39 | } 40 | 41 | function getLinkmanLastTime(linkman: Linkman): number { 42 | let time = linkman.createTime; 43 | const messages = Object.values(linkman.messages); 44 | if (messages.length > 0) { 45 | time = messages[messages.length - 1].createTime; 46 | } 47 | return new Date(time).getTime(); 48 | } 49 | 50 | function sort(linkman1: Linkman, linkman2: Linkman): number { 51 | return getLinkmanLastTime(linkman1) < getLinkmanLastTime(linkman2) 52 | ? 1 53 | : -1; 54 | } 55 | 56 | return ( 57 |
58 | {Object.values(linkmans) 59 | .sort(sort) 60 | .map((linkman) => renderLinkman(linkman))} 61 |
62 | ); 63 | } 64 | 65 | export default LinkmanList; 66 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint-config-airbnb", "prettier"], 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json", 6 | "createDefaultProgram": true 7 | }, 8 | "env": { 9 | "browser": true, 10 | "node": true, 11 | "jest/globals": true 12 | }, 13 | "plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-a11y", "import", "jest"], 14 | "globals": { 15 | "importScripts": true, 16 | "workbox": true, 17 | "__TEST__": true 18 | }, 19 | "settings": { 20 | "import/resolver": { 21 | "node": { 22 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 23 | } 24 | } 25 | }, 26 | "rules": { 27 | "@typescript-eslint/no-unused-vars": 2, 28 | "global-require": 0, 29 | "implicit-arrow-linebreak": 0, 30 | "import/extensions": [ 31 | 2, 32 | { 33 | "ts": "never", 34 | "tsx": "never", 35 | "js": "never", 36 | "jsx": "never" 37 | } 38 | ], 39 | "indent": [ 40 | 2, 41 | 4, 42 | { 43 | "SwitchCase": 1 44 | } 45 | ], 46 | "jsx-a11y/click-events-have-key-events": 0, 47 | "jsx-a11y/interactive-supports-focus": 0, 48 | "jsx-a11y/no-noninteractive-element-interactions": 0, 49 | "jsx-a11y/no-noninteractive-element-to-interactive-role": 0, 50 | "no-param-reassign": 0, 51 | "no-plusplus": 0, 52 | "no-script-url": 0, 53 | "no-underscore-dangle": 0, 54 | "object-curly-newline": 0, 55 | "react/jsx-filename-extension": [ 56 | 2, 57 | { 58 | "extensions": [".js", ".jsx", ".tsx"] 59 | } 60 | ], 61 | "react/jsx-indent": [2, 4], 62 | "react/jsx-indent-props": [2, 4], 63 | "react/jsx-props-no-spreading": 0, 64 | "react/jsx-one-expression-per-line": 0, 65 | "react/static-property-placement": 0, 66 | "react-hooks/rules-of-hooks": 2, 67 | "react-hooks/exhaustive-deps": 0, 68 | "react/require-default-props": [2, { "ignoreFunctionalComponents": true }], 69 | "import/prefer-default-export": 0, 70 | "prefer-promise-reject-errors": "off" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/registerRoutes.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import logger from '@bulita/utils/logger'; 3 | import { getSocketIp } from '@bulita/utils/socket'; 4 | import { Socket } from 'socket.io'; 5 | 6 | function defaultCallback() { 7 | logger.error('Server Error: emit event with callback'); 8 | } 9 | 10 | export default function registerRoutes(socket: Socket, routes: Routes) { 11 | return async ([event, data, cb = defaultCallback]: MiddlewareArgs) => { 12 | const route = routes[event]; 13 | if (route) { 14 | try { 15 | const ctx: Context = { 16 | data, 17 | socket: { 18 | id: socket.id, 19 | ip: getSocketIp(socket), 20 | get user() { 21 | return socket.data.user; 22 | }, 23 | set user(newUserId: string) { 24 | socket.data.user = newUserId; 25 | }, 26 | get isAdmin() { 27 | return socket.data.isAdmin; 28 | }, 29 | join: socket.join.bind(socket), 30 | leave: socket.leave.bind(socket), 31 | emit: (target, _event, _data) => { 32 | socket.to(target).emit(_event, _data); 33 | }, 34 | }, 35 | }; 36 | const before = Date.now(); 37 | const res = await route(ctx); 38 | const after = Date.now(); 39 | // logger.info( 40 | // `[${event}]`, 41 | // after - before, 42 | // ctx.socket.id, 43 | // ctx.socket.user || 'null', 44 | // typeof res === 'string' ? res : 'null', 45 | // ); 46 | cb(res); 47 | } catch (err) { 48 | if (err instanceof assert.AssertionError) { 49 | cb(err.message); 50 | } else { 51 | logger.error(`[${event}]`, err.message); 52 | cb(`Server Error: ${err.message}`); 53 | } 54 | } 55 | } else { 56 | cb(`Server Error: event [${event}] not exists`); 57 | } 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bulita", 3 | "version": "1.0.0", 4 | "description": "An interesting chat application power by socket.io, koa, mongodb and react", 5 | "license": "MIT", 6 | "bin": "index.ts", 7 | "scripts": { 8 | "start": "npx lerna run start --stream", 9 | "dev:server": "npx lerna run dev:server --stream", 10 | "dev:web": "npx lerna run dev:web --stream", 11 | "build:web": "npx lerna run build:web --stream", 12 | "dev:app": "cd packages/app && yarn dev:app && cd ../../", 13 | "build:android": "cd packages/app && yarn build:android && cd ../../", 14 | "build:ios": "cd packages/app && yarn build:ios && cd ../../", 15 | "script": "npx lerna run script --stream", 16 | "dev:docs": "npx lerna run dev:docs --stream", 17 | "build:docs": "npx lerna run build:docs --stream", 18 | "deploy:docs": "npx lerna run deploy:docs --stream", 19 | "lint": "eslint ./ --ext js,jsx,ts,tsx --ignore-pattern .eslintignore --cache --fix", 20 | "test": "jest", 21 | "ts-check": "tsc --noEmit", 22 | "install": "npx lerna bootstrap && yarn link" 23 | }, 24 | "engines": { 25 | "node": ">= 14" 26 | }, 27 | "author": { 28 | "name": "bulita", 29 | "email": "support@bulita.net" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/gochendong/bulita" 34 | }, 35 | "devDependencies": { 36 | "@testing-library/jest-dom": "^4.2.4", 37 | "@testing-library/react": "^12.0.0", 38 | "@types/jest": "^24.0.18", 39 | "@types/node": "^15.14.1", 40 | "@typescript-eslint/eslint-plugin": "^2.0.0", 41 | "@typescript-eslint/parser": "^2.0.0", 42 | "eslint": "^7.30.0", 43 | "eslint-config-airbnb": "^18.2.1", 44 | "eslint-config-prettier": "^8.3.0", 45 | "eslint-plugin-import": "^2.23.4", 46 | "eslint-plugin-jest": "^24.3.6", 47 | "eslint-plugin-jsx-a11y": "^6.4.1", 48 | "eslint-plugin-react": "^7.24.0", 49 | "eslint-plugin-react-hooks": "^4.2.0", 50 | "jest": "^26.1.0", 51 | "lerna": "^4.0.0", 52 | "redis-mock": "^0.56.3", 53 | "ts-jest": "^26.1.3", 54 | "ts-node": "^10.1.0", 55 | "typescript": "^3.8.2" 56 | }, 57 | "dependencies": { 58 | "commander": "^8.0.0", 59 | "dompurify": "^3.0.2", 60 | "marked": "^4.3.0", 61 | "moment": "^2.29.4", 62 | "moment-timezone": "^0.5.43", 63 | "react-markdown": "^8.0.7", 64 | "react-syntax-highlighter": "^15.5.0", 65 | "xmlhttprequest": "^1.8.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/Register.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import platform from 'platform'; 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import getFriendId from '@bulita/utils/getFriendId'; 6 | import convertMessage from '@bulita/utils/convertMessage'; 7 | import Style from './LoginRegister.less'; 8 | import Input from '../../components/Input'; 9 | import useAction from '../../hooks/useAction'; 10 | import { register, getLinkmansLastMessagesV2 } from '../../service'; 11 | // import { Message } from '../../state/reducer'; 12 | import Message from '../../components/Message'; 13 | import { ActionTypes } from '../../state/action'; 14 | 15 | /** 登录框 */ 16 | function Register() { 17 | const action = useAction(); 18 | const dispatch = useDispatch(); 19 | const [username, setUsername] = useState(''); 20 | const [password, setPassword] = useState(''); 21 | 22 | async function handleRegister() { 23 | const user = await register( 24 | username, 25 | password, 26 | platform.os?.family, 27 | platform.name, 28 | platform.description, 29 | ); 30 | if (user) { 31 | action.setUser(user); 32 | action.toggleLoginRegisterDialog(false); 33 | window.localStorage.setItem('token', user.token); 34 | 35 | const linkmanIds = [ 36 | ...user.groups.map((group: any) => group._id), 37 | ...user.friends.map((friend: any) => 38 | getFriendId(friend.from, friend.to._id), 39 | ), 40 | ]; 41 | const linkmanMessages = await getLinkmansLastMessagesV2(linkmanIds); 42 | Object.values(linkmanMessages).forEach( 43 | // @ts-ignore 44 | ({ messages }: { messages: Message[] }) => { 45 | messages.forEach(convertMessage); 46 | }, 47 | ); 48 | dispatch({ 49 | type: ActionTypes.SetLinkmansLastMessages, 50 | payload: linkmanMessages, 51 | }); 52 | } 53 | } 54 | 55 | return ( 56 |
57 | 64 |
65 | ); 66 | } 67 | 68 | export default Register; 69 | -------------------------------------------------------------------------------- /packages/bin/scripts/register.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Register 3 | */ 4 | 5 | import bcrypt from 'bcryptjs'; 6 | import chalk from 'chalk'; 7 | 8 | import initMongoDB from '@bulita/database/mongoose/initMongoDB'; 9 | import User, { UserDocument } from '../../database/mongoose/models/user'; 10 | import Group from '../../database/mongoose/models/group'; 11 | 12 | import { SALT_ROUNDS } from '../../utils/const'; 13 | import getRandomAvatar from '../../utils/getRandomAvatar'; 14 | 15 | export async function register(username: string, password: string) { 16 | if (!username) { 17 | console.log(chalk.red('Wrong command, [username] is missing.')); 18 | return; 19 | } 20 | if (!password) { 21 | console.log(chalk.red('Wrong command, [password] is missing.')); 22 | return; 23 | } 24 | 25 | await initMongoDB(); 26 | 27 | const user = await User.findOne({ username }); 28 | if (user) { 29 | console.log(chalk.red('The username already exists')); 30 | return; 31 | } 32 | 33 | const defaultGroup = await Group.findOne({ isDefault: true }); 34 | if (!defaultGroup) { 35 | console.log(chalk.red('Default group does not exist')); 36 | return; 37 | } 38 | 39 | const salt = await bcrypt.genSalt(SALT_ROUNDS); 40 | const hash = await bcrypt.hash(password, salt); 41 | 42 | let newUser = null; 43 | try { 44 | newUser = await User.create({ 45 | username, 46 | salt, 47 | password: hash, 48 | avatar: getRandomAvatar(), 49 | } as UserDocument); 50 | } catch (createError) { 51 | if (createError.name === 'ValidationError') { 52 | console.log( 53 | chalk.red( 54 | 'Username contains unsupported characters or the length exceeds the limit', 55 | ), 56 | ); 57 | return; 58 | } 59 | console.log(chalk.red('Error:'), createError); 60 | return; 61 | } 62 | 63 | if (!defaultGroup.creator) { 64 | defaultGroup.creator = newUser._id; 65 | } 66 | if (newUser) { 67 | defaultGroup.members.push(newUser._id); 68 | } 69 | await defaultGroup.save(); 70 | 71 | console.log(chalk.green(`Successfully created user [${username}]`)); 72 | } 73 | 74 | async function run() { 75 | const username = process.argv[3]; 76 | const password = process.argv[4]; 77 | await register(username, password); 78 | process.exit(0); 79 | } 80 | export default run; 81 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/frequency.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import { 3 | getNewUserKey, 4 | getSealUserKey, 5 | Redis, 6 | } from '@bulita/database/redis/initRedis'; 7 | 8 | export const CALL_SERVICE_FREQUENTLY = '发消息过于频繁, 请冷静一会再试'; 9 | export const NEW_USER_CALL_SERVICE_FREQUENTLY = 10 | '发消息过于频繁, 你还处于新手期, 先冷静一会再试'; 11 | 12 | const MaxCallPerMinutes = parseInt(process.env.MAX_CALL_PER_MINUTES); 13 | const NewUserMaxCallPerMinutes = parseInt( 14 | process.env.NEW_USER_MAX_CALL_PER_MINUTES, 15 | ); 16 | 17 | const AutoSealDuration = parseInt(process.env.LIFT_BAN_DURATION); 18 | 19 | type Options = { 20 | maxCallPerMinutes?: number; 21 | newUserMaxCallPerMinutes?: number; 22 | clearDataInterval?: number; 23 | }; 24 | 25 | /** 26 | * 限制接口调用频率 27 | */ 28 | export default function frequency( 29 | socket: Socket, 30 | { 31 | maxCallPerMinutes = MaxCallPerMinutes, 32 | newUserMaxCallPerMinutes = NewUserMaxCallPerMinutes, 33 | }: Options = {}, 34 | ) { 35 | let callTimes: Record = {}; 36 | 37 | // 每60s清空一次次数统计 38 | setInterval(() => { 39 | callTimes = {}; 40 | }, Redis.Minute * 1000); 41 | 42 | return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 43 | if (event !== 'sendMessage') { 44 | next(); 45 | } else { 46 | const socketId = socket.id; 47 | const count = callTimes[socketId] || 0; 48 | 49 | const isNewUser = 50 | socket.data.user && 51 | (await Redis.has(getNewUserKey(socket.data.user))); 52 | if (isNewUser && count >= newUserMaxCallPerMinutes) { 53 | // new user limit 54 | cb(NEW_USER_CALL_SERVICE_FREQUENTLY); 55 | await Redis.set( 56 | getSealUserKey(socket.data.user), 57 | socket.data.user, 58 | AutoSealDuration, 59 | ); 60 | callTimes = {}; 61 | } else if (count >= maxCallPerMinutes) { 62 | // normal user limit 63 | cb(CALL_SERVICE_FREQUENTLY); 64 | await Redis.set( 65 | getSealUserKey(socket.data.user), 66 | socket.data.user, 67 | AutoSealDuration, 68 | ); 69 | callTimes = {}; 70 | } else { 71 | callTimes[socketId] = count + 1; 72 | next(); 73 | } 74 | } 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Expression.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .expression { 4 | height: 100%; 5 | 6 | :global { 7 | .rc-tabs { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | .rc-tabs-content { 13 | flex: 1; 14 | } 15 | } 16 | } 17 | 18 | .defaultExpression { 19 | width: 100%; 20 | height: 100%; 21 | display: flex; 22 | flex-direction: row; 23 | flex-wrap: wrap; 24 | padding: 0 7px; 25 | } 26 | 27 | .defaultExpressionBlock { 28 | width: 40px; 29 | height: 40px;; 30 | padding: 5px; 31 | transition: all 0.3s; 32 | 33 | &:hover { 34 | background-color: #e9e9e9; 35 | cursor: pointer; 36 | } 37 | 38 | @media @mobile { 39 | width: 10%; 40 | height: auto; 41 | } 42 | } 43 | 44 | .defaultExpressionItem { 45 | width: 30px; 46 | height: 30px; 47 | background-repeat: no-repeat; 48 | background-size: 30px auto; 49 | background-image: url('@bulita/assets/images/baidu.png'); 50 | } 51 | 52 | .searchExpression { 53 | width: 100%; 54 | height: 100%; 55 | display: flex; 56 | flex-direction: column; 57 | padding: 0 10px; 58 | position: relative; 59 | } 60 | 61 | .searchExpressionInputBlock { 62 | height: 40px; 63 | display: flex; 64 | align-items: center; 65 | } 66 | 67 | .searchExpressionInput { 68 | flex: 1; 69 | width: 100%; 70 | height: 100%; 71 | border-radius: 6px; 72 | border: 1px solid rgba(0, 0, 0, 0.2); 73 | //padding: 0 34px 0 8px; 74 | font-size: 14px; 75 | color: #333; 76 | box-sizing: border-box; 77 | } 78 | 79 | .searchExpressionButton { 80 | height: 34px; 81 | width: 100px; 82 | margin-left: 12px; 83 | } 84 | 85 | .loading { 86 | position: absolute; 87 | top: 50%; 88 | left: 50%; 89 | transform: translate(-50%, -50%); 90 | } 91 | 92 | .searchResult { 93 | height: 155px; 94 | overflow-y: auto; 95 | -webkit-overflow-scrolling: touch; 96 | padding-left: 2px; 97 | } 98 | 99 | .searchImage { 100 | width: 90px; 101 | height: 90px; 102 | margin: 0 4px; 103 | display: inline-block; 104 | position: relative; 105 | 106 | & > img { 107 | max-width: 90px; 108 | max-height: 90px; 109 | position: absolute; 110 | left: 50%; 111 | top: 50%; 112 | transform: translate(-50%, -50%); 113 | } 114 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/CodeMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import loadable from '@loadable/component'; 3 | 4 | import Style from './CodeMessage.less'; 5 | 6 | const CodeDialogAsync = loadable( 7 | () => 8 | // @ts-ignore 9 | import(/* webpackChunkName: "code-dialog" */ './CodeDialog'), 10 | ); 11 | 12 | type LanguageMap = { 13 | [language: string]: string; 14 | }; 15 | 16 | const languagesMap: LanguageMap = { 17 | golang: 'go', 18 | python: 'python', 19 | java: 'java', 20 | javascript: 'javascript', 21 | typescript: 'typescript', 22 | c_cpp: 'cpp', 23 | ruby: 'ruby', 24 | php: 'php', 25 | csharp: 'csharp', 26 | html: 'html', 27 | css: 'css', 28 | sql: 'sql', 29 | json: 'json', 30 | text: 'text', 31 | }; 32 | 33 | interface CodeMessageProps { 34 | code: string; 35 | } 36 | 37 | function CodeMessage(props: CodeMessageProps) { 38 | const { code } = props; 39 | 40 | const [codeDialog, toggleCodeDialog] = useState(false); 41 | 42 | const parseResult = /@language=([_a-z]+)@/.exec(code); 43 | if (!parseResult) { 44 | return
不支持的编程语言
; 45 | } 46 | 47 | const language = languagesMap[parseResult[1]] || 'text'; 48 | const rawCode = code.replace(/@language=[_a-z]+@/, ''); 49 | let size = `${rawCode.length}B`; 50 | if (rawCode.length > 1024) { 51 | size = `${Math.ceil((rawCode.length / 1024) * 100) / 100}KB`; 52 | } 53 | 54 | return ( 55 | <> 56 |
toggleCodeDialog(true)} 59 | role="button" 60 | > 61 |
62 | {/*
*/} 63 | {/* */} 64 | {/*
*/} 65 |
66 | {language}代码 67 | {size} 68 |
69 |
70 |

查看

71 |
72 | {codeDialog && ( 73 | toggleCodeDialog(false)} 76 | language={language} 77 | code={rawCode} 78 | /> 79 | )} 80 | 81 | ); 82 | } 83 | 84 | export default CodeMessage; 85 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/message.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | import Group from './group'; 3 | import User from './user'; 4 | 5 | const MessageSchema = new Schema({ 6 | createTime: { type: Date, default: Date.now, index: true }, 7 | 8 | from: { 9 | type: Schema.Types.ObjectId, 10 | ref: 'User', 11 | }, 12 | to: { 13 | type: String, 14 | index: true, 15 | }, 16 | type: { 17 | type: String, 18 | enum: ['text', 'image', 'file', 'code', 'inviteV2', 'system'], 19 | default: 'text', 20 | }, 21 | content: { 22 | type: String, 23 | default: '', 24 | }, 25 | deleted: { 26 | type: Boolean, 27 | default: false, 28 | }, 29 | }); 30 | 31 | export interface MessageDocument extends Document { 32 | /** 发送人 */ 33 | from: string; 34 | /** 接受者, 发送给群时为群_id, 发送给个人时为俩人的_id按大小序拼接后值 */ 35 | to: string; 36 | /** 类型, text: 文本消息, image: 图片消息, code: 代码消息, invite: 邀请加群消息, system: 系统消息 */ 37 | type: string; 38 | /** 内容, 某些消息类型会存成JSON */ 39 | content: string; 40 | /** 创建时间 */ 41 | createTime: Date; 42 | /** Has it been deleted */ 43 | deleted: boolean; 44 | } 45 | 46 | /** 47 | * Message Model 48 | * 聊天消息 49 | */ 50 | const Message = model('Message', MessageSchema); 51 | 52 | export default Message; 53 | 54 | interface SendMessageData { 55 | to: string; 56 | type: string; 57 | content: string; 58 | } 59 | 60 | export async function handleInviteV2Message(message: SendMessageData) { 61 | if (message.type === 'inviteV2') { 62 | const inviteInfo = JSON.parse(message.content); 63 | if (inviteInfo.inviter && inviteInfo.group) { 64 | const [user, group] = await Promise.all([ 65 | User.findOne({ _id: inviteInfo.inviter }), 66 | Group.findOne({ _id: inviteInfo.group }), 67 | ]); 68 | if (user && group) { 69 | message.content = JSON.stringify({ 70 | inviter: inviteInfo.inviter, 71 | inviterName: user?.username, 72 | group: inviteInfo.group, 73 | groupName: group.name, 74 | }); 75 | } 76 | } 77 | } 78 | } 79 | 80 | export async function handleInviteV2Messages(messages: SendMessageData[]) { 81 | return Promise.all( 82 | messages.map(async (message) => { 83 | if (message.type === 'inviteV2') { 84 | await handleInviteV2Message(message); 85 | } 86 | }), 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /packages/web/src/utils/uploadFile.ts: -------------------------------------------------------------------------------- 1 | import * as OSS from 'ali-oss'; 2 | import fetch from './fetch'; 3 | 4 | let ossClient: OSS; 5 | let endpoint = '/'; 6 | export async function initOSS() { 7 | const [, token] = await fetch('getSTS'); 8 | if (token?.enable) { 9 | // @ts-ignore 10 | ossClient = new OSS({ 11 | region: token.region, 12 | accessKeyId: token.AccessKeyId, 13 | accessKeySecret: token.AccessKeySecret, 14 | stsToken: token.SecurityToken, 15 | bucket: token.bucket, 16 | }); 17 | if (token.endpoint) { 18 | endpoint = `//${token.endpoint}/`; 19 | } 20 | 21 | const OneHour = 1000 * 60 * 60; 22 | setInterval(async () => { 23 | const [, refreshToken] = await fetch('getSTS'); 24 | if (refreshToken?.enable) { 25 | // @ts-ignore 26 | ossClient = new OSS({ 27 | region: refreshToken.region, 28 | accessKeyId: refreshToken.AccessKeyId, 29 | accessKeySecret: refreshToken.AccessKeySecret, 30 | stsToken: refreshToken.SecurityToken, 31 | bucket: refreshToken.bucket, 32 | }); 33 | } 34 | }, OneHour); 35 | } 36 | } 37 | 38 | export function getOSSFileUrl(url = '', process = '') { 39 | const [rawUrl = '', extraPrams = ''] = url.split('?'); 40 | if (ossClient && rawUrl.startsWith('oss:')) { 41 | const filename = rawUrl.slice(4); 42 | // expire 5min 43 | return `${ossClient.signatureUrl(filename, { expires: 300, process })}${ 44 | extraPrams ? `&${extraPrams}` : '' 45 | }`; 46 | } 47 | if (/\/\/cdn\.suisuijiang\.com/.test(rawUrl)) { 48 | return `${rawUrl}?x-oss-process=${process}${ 49 | extraPrams ? `&${extraPrams}` : '' 50 | }`; 51 | } 52 | return `${url}`; 53 | } 54 | 55 | /** 56 | * 上传文件 57 | * @param blob 文件blob数据 58 | * @param fileName 文件名 59 | */ 60 | export default async function uploadFile( 61 | blob: Blob, 62 | fileName: string, 63 | ): Promise { 64 | // 阿里云 OSS 不可用, 上传文件到服务端 65 | if (!ossClient) { 66 | const [uploadErr, result] = await fetch('uploadFile', { 67 | file: blob, 68 | fileName, 69 | }); 70 | if (uploadErr) { 71 | throw Error(uploadErr); 72 | } 73 | return result.url; 74 | } 75 | 76 | // 上传到阿里OSS 77 | const result = await ossClient.put(fileName, blob); 78 | if (result.res.status === 200) { 79 | return endpoint + result.name; 80 | } 81 | return Promise.reject('上传文件失败'); 82 | } 83 | -------------------------------------------------------------------------------- /packages/utils/snowflake.ts: -------------------------------------------------------------------------------- 1 | function Snowflake(_workerId, _dataCenterId, _sequence) { 2 | this.twepoch = 1288834974657n; 3 | // this.twepoch = 0n; 4 | this.workerIdBits = 5n; 5 | this.dataCenterIdBits = 5n; 6 | this.maxWrokerId = -1n ^ (-1n << this.workerIdBits); // 值为:31 7 | this.maxDataCenterId = -1n ^ (-1n << this.dataCenterIdBits); // 值为:31 8 | this.sequenceBits = 12n; 9 | this.workerIdShift = this.sequenceBits; // 值为:12 10 | this.dataCenterIdShift = this.sequenceBits + this.workerIdBits; // 值为:17 11 | this.timestampLeftShift = 12 | this.sequenceBits + this.workerIdBits + this.dataCenterIdBits; // 值为:22 13 | this.sequenceMask = -1n ^ (-1n << this.sequenceBits); // 值为:4095 14 | this.lastTimestamp = -1n; 15 | // 设置默认值,从环境变量取 16 | this.workerId = 1n; 17 | this.dataCenterId = 1n; 18 | this.sequence = 0n; 19 | if (this.workerId > this.maxWrokerId || this.workerId < 0) { 20 | throw new Error( 21 | `_workerId must max than 0 and small than maxWrokerId-[${this.maxWrokerId}]`, 22 | ); 23 | } 24 | if (this.dataCenterId > this.maxDataCenterId || this.dataCenterId < 0) { 25 | throw new Error( 26 | `_dataCenterId must max than 0 and small than maxDataCenterId-[${this.maxDataCenterId}]`, 27 | ); 28 | } 29 | 30 | this.workerId = BigInt(_workerId); 31 | this.dataCenterId = BigInt(_dataCenterId); 32 | this.sequence = BigInt(_sequence); 33 | } 34 | Snowflake.prototype.tilNextMillis = function (lastTimestamp) { 35 | let timestamp = this.timeGen(); 36 | while (timestamp <= lastTimestamp) { 37 | timestamp = this.timeGen(); 38 | } 39 | return BigInt(timestamp); 40 | }; 41 | Snowflake.prototype.timeGen = function () { 42 | return BigInt(Date.now()); 43 | }; 44 | Snowflake.prototype.nextId = function () { 45 | let timestamp = this.timeGen(); 46 | if (timestamp < this.lastTimestamp) { 47 | throw new Error( 48 | `Clock moved backwards. Refusing to generate id for ${ 49 | this.lastTimestamp - timestamp 50 | }`, 51 | ); 52 | } 53 | if (this.lastTimestamp === timestamp) { 54 | this.sequence = (this.sequence + 1n) & this.sequenceMask; 55 | if (this.sequence === 0n) { 56 | timestamp = this.tilNextMillis(this.lastTimestamp); 57 | } 58 | } else { 59 | this.sequence = 0n; 60 | } 61 | this.lastTimestamp = timestamp; 62 | return ( 63 | ((timestamp - this.twepoch) << this.timestampLeftShift) | 64 | (this.dataCenterId << this.dataCenterIdShift) | 65 | (this.workerId << this.workerIdShift) | 66 | this.sequence 67 | ); 68 | }; 69 | 70 | export default Snowflake; 71 | -------------------------------------------------------------------------------- /packages/web/src/utils/inobounce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 阻止目标元素下不必要的滚动事件. 解决ios橡皮筋效果问题 3 | * @param {HTMLElement} targetElement 目标元素 4 | */ 5 | export default function inobounce(targetElement: HTMLElement) { 6 | let startX = 0; 7 | let startY = 0; 8 | 9 | function handleTouchStart(e: any) { 10 | startY = e.touches ? e.touches[0].screenY : e.screenY; 11 | startX = e.touches ? e.touches[0].screenX : e.screenX; 12 | } 13 | function handleTouchMove(e: any) { 14 | let el = e.target; 15 | 16 | while (el !== e.currentTarget) { 17 | const style = window.getComputedStyle(el); 18 | 19 | if (!style) { 20 | break; 21 | } 22 | 23 | if ( 24 | el.nodeName === 'INPUT' && 25 | el.getAttribute('type') === 'range' 26 | ) { 27 | return; 28 | } 29 | 30 | const overflowY = style.getPropertyValue('overflow-y'); 31 | const height = +parseFloat( 32 | style.getPropertyValue('height'), 33 | ).toFixed(0); 34 | const isScrollableY = 35 | overflowY === 'auto' || overflowY === 'scroll'; 36 | const canScrollY = el.scrollHeight > el.offsetHeight; 37 | if (isScrollableY && canScrollY) { 38 | const curY = e.touches ? e.touches[0].screenY : e.screenY; 39 | const isAtTop = startY <= curY && el.scrollTop === 0; 40 | const isAtBottom = 41 | startY >= curY && el.scrollHeight - el.scrollTop === height; 42 | 43 | if (isAtTop || isAtBottom) { 44 | e.preventDefault(); 45 | } 46 | return; 47 | } 48 | 49 | const overflowX = style.getPropertyValue('overflow-x'); 50 | const width = +parseFloat(style.getPropertyValue('width')).toFixed( 51 | 0, 52 | ); 53 | const isScrollableX = 54 | overflowX === 'auto' || overflowX === 'scroll'; 55 | const canScrollX = el.scrollWidth > el.offsetWidth; 56 | if (isScrollableX && canScrollX) { 57 | const curX = e.touches ? e.touches[0].screenX : e.screenX; 58 | const isAtLeft = startX <= curX && el.scrollLeft === 0; 59 | const isAtRight = 60 | startX >= curX && el.scrollWidth - el.scrollLeft === width; 61 | 62 | if (isAtLeft || isAtRight) { 63 | e.preventDefault(); 64 | } 65 | return; 66 | } 67 | 68 | el = el.parentNode; 69 | } 70 | 71 | e.preventDefault(); 72 | } 73 | 74 | if (targetElement) { 75 | targetElement.addEventListener('touchstart', handleTouchStart); 76 | targetElement.addEventListener('touchmove', handleTouchMove); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/server/test/middlewares/frequency.spec.ts: -------------------------------------------------------------------------------- 1 | import { mocked } from 'ts-jest/utils'; 2 | import { Redis } from '@bulita/database/redis/initRedis'; 3 | import { Socket } from 'socket.io'; 4 | import frequency, { 5 | CALL_SERVICE_FREQUENTLY, 6 | NEW_USER_CALL_SERVICE_FREQUENTLY, 7 | } from '../../src/middlewares/frequency'; 8 | import { getMiddlewareParams } from '../helpers/middleware'; 9 | 10 | jest.mock('@bulita/database/redis/initRedis'); 11 | jest.useFakeTimers(); 12 | 13 | describe('server/middlewares/frequency', () => { 14 | it('should response call service frequently', async () => { 15 | const socket = { 16 | id: 'id', 17 | data: {}, 18 | } as Socket; 19 | const middleware = frequency(socket, { 20 | maxCallPerMinutes: 3, 21 | }); 22 | 23 | const { args, cb, next } = getMiddlewareParams('sendMessage'); 24 | 25 | await middleware(args, next); 26 | expect(next).toBeCalledTimes(1); 27 | 28 | await middleware(args, next); 29 | await middleware(args, next); 30 | await middleware(args, next); 31 | expect(cb).toBeCalledWith(CALL_SERVICE_FREQUENTLY); 32 | }); 33 | 34 | it('should response success when event is not sendMessage', async () => { 35 | const socket = { 36 | id: 'id', 37 | } as Socket; 38 | const middleware = frequency(socket, { 39 | maxCallPerMinutes: 1, 40 | }); 41 | 42 | const { args, next } = getMiddlewareParams('login'); 43 | 44 | await middleware(args, next); 45 | await middleware(args, next); 46 | expect(next).toBeCalledTimes(2); 47 | }); 48 | 49 | it('should stricter for new user', async () => { 50 | const socket = { 51 | id: 'id', 52 | data: { 53 | user: '1', 54 | }, 55 | } as Socket; 56 | const middleware = frequency(socket, { 57 | maxCallPerMinutes: 3, 58 | newUserMaxCallPerMinutes: 1, 59 | }); 60 | 61 | const { args, cb, next } = getMiddlewareParams('sendMessage'); 62 | 63 | mocked(Redis.has).mockReturnValue(Promise.resolve(true)); 64 | await middleware(args, next); 65 | await middleware(args, next); 66 | expect(cb).toBeCalledWith(NEW_USER_CALL_SERVICE_FREQUENTLY); 67 | }); 68 | 69 | it('should clear count data regularly ', async () => { 70 | const socket = { 71 | id: 'id', 72 | data: {}, 73 | } as Socket; 74 | const middleware = frequency(socket, { 75 | maxCallPerMinutes: 1, 76 | clearDataInterval: 1000, 77 | }); 78 | 79 | const { args, cb, next } = getMiddlewareParams('sendMessage'); 80 | 81 | await middleware(args, next); 82 | await middleware(args, next); 83 | expect(cb).toBeCalledWith(CALL_SERVICE_FREQUENTLY); 84 | 85 | jest.advanceTimersByTime(1000); 86 | await middleware(args, next); 87 | expect(next).toBeCalledTimes(2); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/web/src/localStorage.ts: -------------------------------------------------------------------------------- 1 | import config from '@bulita/config/client'; 2 | import themes from './themes'; 3 | 4 | /** LocalStorage存储的键值 */ 5 | export enum LocalStorageKey { 6 | Theme = 'theme', 7 | PrimaryColor = 'primaryColor', 8 | PrimaryTextColor = 'primaryTextColor', 9 | BackgroundImage = 'backgroundImage', 10 | Aero = 'aero', 11 | Sound = 'sound', 12 | SoundSwitch = 'soundSwitch', 13 | NotificationSwitch = 'notificationSwitch', 14 | VoiceSwitch = 'voiceSwitch', 15 | SelfVoiceSwitch = 'selfVoiceSwitch', 16 | TagColorMode = 'tagColorMode', 17 | EnableSearchExpression = 'enableSearchExpression', 18 | } 19 | 20 | /** 21 | * 获取LocalStorage中的文本值 22 | * @param key 键值 23 | * @param defaultValue 默认值 24 | */ 25 | function getTextValue(key: string, defaultValue: string) { 26 | const value = window.localStorage.getItem(key); 27 | return value || defaultValue; 28 | } 29 | 30 | /** 31 | * 获取LocalStorage中的boolean值 32 | * @param key 键值 33 | * @param defaultValue 默认值 34 | */ 35 | function getSwitchValue(key: string, defaultValue: boolean = true) { 36 | const value = window.localStorage.getItem(key); 37 | return value ? value === 'true' : defaultValue; 38 | } 39 | 40 | /** 41 | * 获取LocalStorage值 42 | */ 43 | export default function getData() { 44 | const theme = getTextValue(LocalStorageKey.Theme, config.defaultTheme); 45 | let themeConfig = { 46 | primaryColor: '', 47 | primaryTextColor: '', 48 | backgroundImage: '', 49 | aero: false, 50 | }; 51 | // @ts-ignore 52 | if (theme && themes[theme]) { 53 | // @ts-ignore 54 | themeConfig = themes[theme]; 55 | } else { 56 | themeConfig = { 57 | primaryColor: getTextValue( 58 | LocalStorageKey.PrimaryColor, 59 | themes[config.defaultTheme]?.primaryColor, 60 | ), 61 | primaryTextColor: getTextValue( 62 | LocalStorageKey.PrimaryTextColor, 63 | themes[config.defaultTheme]?.primaryTextColor, 64 | ), 65 | backgroundImage: getTextValue( 66 | LocalStorageKey.BackgroundImage, 67 | themes[config.defaultTheme]?.backgroundImage, 68 | ), 69 | aero: getSwitchValue(LocalStorageKey.Aero, false), 70 | }; 71 | } 72 | return { 73 | theme, 74 | ...themeConfig, 75 | sound: getTextValue(LocalStorageKey.Sound, config.sound), 76 | soundSwitch: getSwitchValue(LocalStorageKey.SoundSwitch), 77 | notificationSwitch: getSwitchValue(LocalStorageKey.NotificationSwitch), 78 | voiceSwitch: getSwitchValue(LocalStorageKey.VoiceSwitch), 79 | selfVoiceSwitch: getSwitchValue(LocalStorageKey.SelfVoiceSwitch, false), 80 | tagColorMode: getTextValue( 81 | LocalStorageKey.TagColorMode, 82 | config.tagColorMode, 83 | ), 84 | enableSearchExpression: getSwitchValue( 85 | LocalStorageKey.EnableSearchExpression, 86 | true, 87 | ), 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Setting.less: -------------------------------------------------------------------------------- 1 | .setting { 2 | :global { 3 | .rc-dialog-body { 4 | overflow-y: hidden; 5 | } 6 | .rc-tabs-top { 7 | border-bottom: none; 8 | } 9 | } 10 | } 11 | 12 | .switchContainer { 13 | margin-bottom: 4px; 14 | display: flex; 15 | flex-wrap: wrap; 16 | } 17 | 18 | .button { 19 | margin-right: 10px; 20 | width: 130px; 21 | height: 34px; 22 | } 23 | 24 | .switch { 25 | display: flex; 26 | align-items: center; 27 | margin-right: 10px; 28 | margin-bottom: 8px; 29 | } 30 | 31 | .switchText { 32 | color: #444; 33 | margin-right: 8px; 34 | font-size: 14px; 35 | } 36 | 37 | .radioGroup { 38 | display: flex !important; 39 | flex-wrap: wrap; 40 | justify-content: flex-start; 41 | 42 | & > div { 43 | width: 120px !important; 44 | flex: initial !important; 45 | padding: 12px !important; 46 | margin-bottom: 6px !important; 47 | & > div { 48 | display: flex; 49 | align-items: center; 50 | } 51 | & > div > div { 52 | padding: 0 !important; 53 | font-size: 14px; 54 | } 55 | & > div > div > div { 56 | transform: translate(-2px, -2px); 57 | } 58 | } 59 | } 60 | 61 | .backgroundTip { 62 | font-size: 12px; 63 | color: #777; 64 | } 65 | 66 | .backgroundImageContainer { 67 | text-align: center; 68 | position: relative; 69 | } 70 | 71 | .backgroundImage { 72 | width: 98%; 73 | height: auto; 74 | cursor: pointer; 75 | 76 | &:hover { 77 | filter: blur(3px); 78 | } 79 | 80 | &.blur { 81 | filter: blur(3px); 82 | } 83 | } 84 | 85 | .backgroundImageLoading { 86 | position: absolute; 87 | top: 70px; 88 | left: 175px; 89 | pointer-events: none; 90 | } 91 | 92 | .colorInfo { 93 | display: flex; 94 | align-items: center; 95 | 96 | & > div { 97 | width: 30px; 98 | height: 30px; 99 | border-radius: 4px; 100 | margin-left: 6px; 101 | } 102 | & > span { 103 | margin-left: 12px; 104 | color: #666; 105 | } 106 | } 107 | 108 | .colorPicker { 109 | margin-top: 20px; 110 | } 111 | 112 | .TagModeRadioGroup { 113 | & > div { 114 | width: 130px !important; 115 | flex: initial !important; 116 | padding: 12px !important; 117 | margin-bottom: 6px !important; 118 | & > div { 119 | display: flex; 120 | align-items: center; 121 | } 122 | & > div > div { 123 | padding: 0 !important; 124 | font-size: 14px; 125 | } 126 | & > div > div > div { 127 | transform: translate(-2px, -2px); 128 | } 129 | } 130 | } 131 | 132 | .scrollContainer { 133 | margin-top: 20px; 134 | overflow-y: auto; 135 | -webkit-overflow-scrolling: touch; 136 | max-height: 52vh; 137 | } -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import platform from 'platform'; 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import getFriendId from '@bulita/utils/getFriendId'; 6 | import convertMessage from '@bulita/utils/convertMessage'; 7 | import Input from '../../components/Input'; 8 | import useAction from '../../hooks/useAction'; 9 | 10 | import Style from './LoginRegister.less'; 11 | import { login, getLinkmansLastMessagesV2 } from '../../service'; 12 | import { Message } from '../../state/reducer'; 13 | import { ActionTypes } from '../../state/action'; 14 | 15 | /** 登录框 */ 16 | function Login() { 17 | const action = useAction(); 18 | const dispatch = useDispatch(); 19 | const [username, setUsername] = useState(''); 20 | const [password, setPassword] = useState(''); 21 | 22 | async function handleLogin() { 23 | const user = await login( 24 | username, 25 | password, 26 | platform.os?.family, 27 | platform.name, 28 | platform.description, 29 | ); 30 | if (user) { 31 | action.setUser(user); 32 | action.toggleLoginRegisterDialog(false); 33 | window.localStorage.setItem('token', user.token); 34 | 35 | const linkmanIds = [ 36 | ...user.groups.map((group: any) => group._id), 37 | ...user.friends.map((friend: any) => 38 | getFriendId(friend.from, friend.to._id), 39 | ), 40 | ]; 41 | const linkmanMessages = await getLinkmansLastMessagesV2(linkmanIds); 42 | Object.values(linkmanMessages).forEach( 43 | // @ts-ignore 44 | ({ messages }: { messages: Message[] }) => { 45 | messages.forEach(convertMessage); 46 | }, 47 | ); 48 | dispatch({ 49 | type: ActionTypes.SetLinkmansLastMessages, 50 | payload: linkmanMessages, 51 | }); 52 | } 53 | } 54 | 55 | return ( 56 |
57 |

用户名

58 | 66 |

密码

67 | 76 | 83 |
84 | ); 85 | } 86 | 87 | export default Login; 88 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/TextMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import expressions from '@bulita/utils/expressions'; 4 | import { TRANSPARENT_IMAGE } from '@bulita/utils/const'; 5 | import { marked } from 'marked'; 6 | import Style from './Message.less'; 7 | // import DOMPurify from 'dompurify' 8 | 9 | interface TextMessageProps { 10 | content: string; 11 | } 12 | 13 | function TextMessage(props: TextMessageProps) { 14 | // const reg = /(http:\/\/|https:\/\/|www)(([\w#]|=|\?|\.|\/|&|~|-|[\u200B-\u200D\uFEFF])+)/g; 15 | // eslint-disable-next-line react/destructuring-assignment 16 | const content = props.content 17 | .replace(/<[^>]*?>/gi, '') 18 | .replace(/(.*?)<\/[^>]*?>/gi, '') 19 | .replace(/\n/g, '
') 20 | .replace( 21 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}(\.[a-z]{2,6})?\b(:[0-9]{2,5})?([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, 22 | (r) => 23 | `${r}`, 24 | ) 25 | .replace(/#\(([\u4e00-\u9fa5a-z]+)\)/g, (r, e) => { 26 | const index = expressions.default.indexOf(e); 27 | if (index !== -1) { 28 | return `${r}`; 33 | } 34 | return r; 35 | }); 36 | 37 | return ( 38 |
43 | ); 44 | } 45 | 46 | function TextMessageBot(props: TextMessageProps) { 47 | // const reg = /(http:\/\/|https:\/\/|www)(([\w#]|=|\?|\.|\/|&|~|-|[\u200B-\u200D\uFEFF])+)/g; 48 | // eslint-disable-next-line react/destructuring-assignment 49 | let content = marked(props.content); 50 | content = content 51 | .replace( 52 | / { 60 | const index = expressions.default.indexOf(e); 61 | if (index !== -1) { 62 | return `${r}`; 67 | } 68 | return r; 69 | }); 70 | return ( 71 |
76 | ); 77 | } 78 | 79 | export { TextMessage, TextMessageBot }; 80 | -------------------------------------------------------------------------------- /packages/web/src/utils/readDiskFile.ts: -------------------------------------------------------------------------------- 1 | export interface ReadFileResult { 2 | /** 文件名 */ 3 | filename: string; 4 | /** 文件拓展名 */ 5 | ext: string; 6 | /** 文件类型 */ 7 | type: string; 8 | /** 文件内容 */ 9 | result: Blob | ArrayBuffer | string; 10 | /** 文件长度 */ 11 | length: number; 12 | } 13 | 14 | /** 15 | * 读取本地文件 16 | * @param {string} resultType 数据类型, {blob|base64}, 默认blob 17 | * @param {string} accept 可选文件类型, 默认 * / * 18 | */ 19 | export default async function readDiskFIle( 20 | resultType = 'blob', 21 | accept = '*/*', 22 | ) { 23 | const result: ReadFileResult | null = await new Promise((resolve) => { 24 | const $input = document.createElement('input'); 25 | $input.style.display = 'none'; 26 | $input.setAttribute('type', 'file'); 27 | $input.setAttribute('accept', accept); 28 | // 判断用户是否点击取消, 原生没有提供专门事件, 用hack的方法实现 29 | $input.onclick = () => { 30 | // @ts-ignore 31 | $input.value = null; 32 | document.body.onfocus = () => { 33 | // onfocus事件会比$input.onchange事件先触发, 因此需要延迟一段时间 34 | setTimeout(() => { 35 | if ($input.value.length === 0) { 36 | resolve(null); 37 | } 38 | document.body.onfocus = null; 39 | }, 500); 40 | }; 41 | }; 42 | $input.onchange = (e: Event) => { 43 | // @ts-ignore 44 | const file = e.target.files[0]; 45 | if (!file) { 46 | return; 47 | } 48 | 49 | const reader = new FileReader(); 50 | reader.onloadend = function handleLoad() { 51 | if (!this.result) { 52 | resolve(null); 53 | return; 54 | } 55 | // @ts-ignore 56 | resolve({ 57 | filename: file.name, 58 | ext: file.name.split('.').pop().toLowerCase(), 59 | type: file.type, 60 | // @ts-ignore 61 | result: this.result, 62 | length: 63 | resultType === 'blob' 64 | ? (this.result as ArrayBuffer).byteLength 65 | : (this.result as string).length, 66 | }); 67 | }; 68 | switch (resultType) { 69 | case 'blob': { 70 | reader.readAsArrayBuffer(file); 71 | break; 72 | } 73 | case 'base64': { 74 | reader.readAsDataURL(file); 75 | break; 76 | } 77 | default: { 78 | reader.readAsArrayBuffer(file); 79 | } 80 | } 81 | }; 82 | $input.click(); 83 | }); 84 | 85 | if (result && resultType === 'blob') { 86 | result.result = new Blob( 87 | [new Uint8Array(result.result as ArrayBuffer)], 88 | { 89 | type: result.type, 90 | }, 91 | ); 92 | } 93 | return result; 94 | } 95 | -------------------------------------------------------------------------------- /packages/database/redis/initRedis.ts: -------------------------------------------------------------------------------- 1 | import redis from 'redis'; 2 | import { promisify } from 'util'; 3 | import config from '@bulita/config/server'; 4 | import logger from '@bulita/utils/logger'; 5 | 6 | export default function initRedis() { 7 | const client = redis.createClient({ 8 | ...config.redis, 9 | }); 10 | 11 | client.on('error', (err) => { 12 | logger.error('[redis]', err.message); 13 | process.exit(0); 14 | }); 15 | 16 | return client; 17 | } 18 | 19 | const Prefix = 'chatroom'; 20 | 21 | const client = initRedis(); 22 | 23 | export const get = promisify(client.get).bind(client); 24 | 25 | export const expire = promisify(client.expire).bind(client); 26 | 27 | export const lpush = promisify(client.lpush).bind(client); 28 | 29 | export const incr = promisify(client.incr).bind(client); 30 | 31 | // 如果是IPv4, 获取地址的前两位, 避免过于容易的逃避IP封禁策略 32 | export function convertIP(ip) { 33 | const parts = ip.split('.'); 34 | if (parts.length >= 2) { 35 | return `${parts[0]}.${parts[1]}`; 36 | } 37 | return ip; // 返回原始 IP 地址,如果无法分割成两部分 38 | } 39 | 40 | export function isValidIP(ip) { 41 | const pattern = /^(\d{1,3}\.){3}\d{1,3}$/; 42 | return pattern.test(ip); 43 | } 44 | 45 | export async function set(key: string, value: string, expireTime = Infinity) { 46 | if (isValidIP(value)) { 47 | value = convertIP(value); 48 | } 49 | await promisify(client.set).bind(client)(key, value); 50 | if (expireTime !== Infinity) { 51 | await expire(key, expireTime); 52 | } 53 | } 54 | 55 | export const keys = promisify(client.keys).bind(client); 56 | 57 | export async function has(key: string) { 58 | const v = await get(key); 59 | return v !== null; 60 | } 61 | 62 | export function getNewUserKey(userId: string) { 63 | return `${Prefix}:NewUser:${userId}`; 64 | } 65 | 66 | export function getNewRegisteredUserIpKey(ip: string) { 67 | // The value of v1 is ip 68 | // The value of v2 is count number 69 | return `${Prefix}:NewRegisteredUserIpV2:${convertIP(ip)}`; 70 | } 71 | 72 | export function getSealIpKey(ip: string) { 73 | return `${Prefix}:SealIp:${convertIP(ip)}`; 74 | } 75 | 76 | export async function getAllSealIp() { 77 | const allSealIpKeys = await keys(`${Prefix}:SealIp:*`); 78 | return allSealIpKeys.map((key) => key.replace(`${Prefix}:SealIp:`, '')); 79 | } 80 | 81 | export function getSealUserKey(user: string) { 82 | return `${Prefix}:SealUser:${user}`; 83 | } 84 | 85 | export async function getAllSealUser() { 86 | const allSealUserKeys = await keys(`${Prefix}:SealUser:*`); 87 | return allSealUserKeys.map((key) => key.replace(`${Prefix}:SealUser:`, '')); 88 | } 89 | 90 | const Minute = 60; 91 | const Hour = Minute * 60; 92 | const Day = Hour * 24; 93 | 94 | export const Redis = { 95 | get, 96 | set, 97 | has, 98 | expire, 99 | keys, 100 | Minute, 101 | Hour, 102 | Day, 103 | lpush, 104 | incr, 105 | }; 106 | 107 | export const DisableSendMessageKey = `${Prefix}:DisableSendMessage`; 108 | export const DisableNewUserSendMessageKey = `${Prefix}:DisableNewUserSendMessageKey`; 109 | export const DisableRegisterUserSendMessageKey = `${Prefix}:DisableNoRegisterUserSendMessageKey`; 110 | export const DisableRegisterUserKey = `${Prefix}:DisableRegisterUserKey`; 111 | -------------------------------------------------------------------------------- /packages/server/src/app.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import koaSend from 'koa-send'; 3 | import koaStatic from 'koa-static'; 4 | import bodyParser from 'koa-bodyparser'; 5 | import path from 'path'; 6 | import http from 'http'; 7 | import { Server } from 'socket.io'; 8 | 9 | import logger from '@bulita/utils/logger'; 10 | import config from '@bulita/config/server'; 11 | import { getSocketIp } from '@bulita/utils/socket'; 12 | import SocketModel, { 13 | SocketDocument, 14 | } from '@bulita/database/mongoose/models/socket'; 15 | 16 | import seal from './middlewares/seal'; 17 | import frequency from './middlewares/frequency'; 18 | import isLogin from './middlewares/isLogin'; 19 | import isAdmin from './middlewares/isAdmin'; 20 | 21 | import * as userRoutes from './routes/user'; 22 | import * as groupRoutes from './routes/group'; 23 | import * as messageRoutes from './routes/message'; 24 | import * as systemRoutes from './routes/system'; 25 | import * as notificationRoutes from './routes/notification'; 26 | import * as historyRoutes from './routes/history'; 27 | import registerRoutes from './middlewares/registerRoutes'; 28 | 29 | const app = new Koa(); 30 | 31 | app.use( 32 | bodyParser({ 33 | formLimit: '1024mb', 34 | jsonLimit: '1024mb', 35 | textLimit: '1024mb', 36 | limit: '1024mb', 37 | }), 38 | ); 39 | 40 | app.proxy = true; 41 | 42 | const httpServer = http.createServer(app.callback()); 43 | const io = new Server(httpServer, { 44 | cors: { 45 | origin: config.allowOrigin || '*', 46 | credentials: true, 47 | }, 48 | pingTimeout: 6000000, 49 | pingInterval: 6000000, 50 | maxHttpBufferSize: 1024 * 1024 * 1024, // 设置为1024MB 51 | }); 52 | 53 | // serve index.html 54 | app.use(async (ctx, next) => { 55 | if ( 56 | /\/invite\/group\/[\w\d]+/.test(ctx.request.url) || 57 | !/(\.)|(\/invite\/group\/[\w\d]+)/.test(ctx.request.url) 58 | ) { 59 | await koaSend(ctx, 'index.html', { 60 | root: path.join(__dirname, '../public'), 61 | maxage: 1000 * 60 * 60 * 24 * 7, 62 | gzip: true, 63 | }); 64 | } else { 65 | await next(); 66 | } 67 | }); 68 | 69 | // serve public static files 70 | app.use( 71 | koaStatic(path.join(__dirname, '../public'), { 72 | maxAge: 1000 * 60 * 60 * 24 * 7, 73 | gzip: true, 74 | }), 75 | ); 76 | 77 | const routes: Routes = { 78 | ...userRoutes, 79 | ...groupRoutes, 80 | ...messageRoutes, 81 | ...systemRoutes, 82 | ...notificationRoutes, 83 | ...historyRoutes, 84 | }; 85 | Object.keys(routes).forEach((key) => { 86 | if (key.startsWith('_')) { 87 | routes[key] = null; 88 | } 89 | }); 90 | 91 | io.on('connection', async (socket) => { 92 | const ip = getSocketIp(socket); 93 | logger.trace(`connection ${socket.id} ${ip}`); 94 | await SocketModel.create({ 95 | id: socket.id, 96 | ip, 97 | } as SocketDocument); 98 | 99 | socket.on('disconnect', async () => { 100 | logger.trace(`disconnect ${socket.id}`); 101 | await SocketModel.deleteOne({ 102 | id: socket.id, 103 | }); 104 | }); 105 | 106 | socket.use(seal(socket)); 107 | socket.use(isLogin(socket)); 108 | socket.use(isAdmin(socket)); 109 | socket.use(frequency(socket)); 110 | socket.use(registerRoutes(socket, routes)); 111 | }); 112 | 113 | export default httpServer; 114 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/GroupManagePanel.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .groupManagePanel { 4 | height: 100%; 5 | width: 300px; 6 | position: absolute; 7 | right: 0; 8 | transition: transform 0.5s; 9 | 10 | @media @mobile { 11 | width: 100%; 12 | background-color: rgba(37, 37, 37, 0.5); 13 | } 14 | } 15 | 16 | .show { 17 | transform: translateX(0); 18 | } 19 | .hide { 20 | transform: translateX(100%); 21 | } 22 | 23 | .container { 24 | width: 300px; 25 | height: 100%; 26 | display: flex; 27 | flex-direction: column; 28 | background-color: rgba(250, 250, 250, 0.95); 29 | border-top-right-radius: 10px; 30 | border-bottom-right-radius: 10px; 31 | position: absolute; 32 | right: 0; 33 | 34 | @media @mobile { 35 | border-top-right-radius: 0; 36 | border-bottom-right-radius: 0; 37 | } 38 | } 39 | 40 | .title { 41 | height: 70px; 42 | border-bottom: 1px solid #e8e8e8; 43 | box-sizing: border-box; 44 | text-align: center; 45 | line-height: 70px; 46 | font-size: 14px; 47 | color: #666; 48 | font-weight: bold; 49 | 50 | @media @mobile { 51 | height: 50px; 52 | } 53 | } 54 | 55 | .content { 56 | flex: 1; 57 | padding: 12px; 58 | overflow-y: auto; 59 | -webkit-overflow-scrolling: touch; 60 | } 61 | 62 | .block { 63 | margin-bottom: 10px; 64 | } 65 | 66 | .blockTitle { 67 | line-height: 33px; 68 | font-size: 14px; 69 | color: #333; 70 | font-weight: bold; 71 | } 72 | 73 | .name { 74 | & > .component-input { 75 | height: 36px; 76 | } 77 | & > .component-button { 78 | height: 36px; 79 | line-height: 36px; 80 | margin-top: 8px; 81 | } 82 | } 83 | .input { 84 | height: 36px; 85 | width: 100%; 86 | border-radius: 6px; 87 | border: 1px solid rgba(0, 0, 0, 0.2); 88 | //padding: 0 34px 0 8px; 89 | font-size: 14px; 90 | color: #333; 91 | box-sizing: border-box; 92 | -webkit-user-select: auto; 93 | -moz-user-select: auto; 94 | -ms-user-select: auto; 95 | user-select: auto; 96 | } 97 | 98 | .button { 99 | height: 36px; 100 | line-height: 36px; 101 | margin-top: 8px; 102 | } 103 | 104 | .avatar { 105 | width: 100px; 106 | height: 100px; 107 | cursor: pointer; 108 | &:hover { 109 | filter: blur(3px); 110 | } 111 | } 112 | 113 | .onlineMember{ 114 | display: flex; 115 | justify-content: space-between; 116 | align-items: center; 117 | margin-bottom: 6px; 118 | cursor: default; 119 | } 120 | 121 | .userinfoBlock { 122 | display: flex; 123 | align-items: center; 124 | cursor: pointer; 125 | } 126 | 127 | .username { 128 | margin-left: 10px; 129 | color: #333; 130 | font-size: 14px; 131 | word-break: keep-all; 132 | max-width: 120px; 133 | } 134 | 135 | .clientInfoText { 136 | color: #666; 137 | font-size: 12px; 138 | overflow: hidden; 139 | white-space: nowrap; 140 | text-overflow: ellipsis; 141 | margin-left: 12px; 142 | text-align: right; 143 | } 144 | 145 | .deleteGroupConfirmDialog { 146 | width: 250px; 147 | :global { 148 | .rc-dialog-body { 149 | display: flex; 150 | justify-content: flex-end; 151 | } 152 | } 153 | } 154 | 155 | .deleteGroupConfirmButton { 156 | width: 60px; 157 | height: 36px; 158 | margin-left: 12px; 159 | } -------------------------------------------------------------------------------- /packages/web/src/modules/GroupInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { getOSSFileUrl } from '../utils/uploadFile'; 5 | import Dialog from '../components/Dialog'; 6 | import Avatar from '../components/Avatar'; 7 | import Button from '../components/Button'; 8 | import { State } from '../state/reducer'; 9 | import useAction from '../hooks/useAction'; 10 | import { joinGroup, getLinkmanHistoryMessages } from '../service'; 11 | 12 | import Style from './InfoDialog.less'; 13 | 14 | interface GroupInfoProps { 15 | visible: boolean; 16 | group?: { 17 | _id: string; 18 | name: string; 19 | avatar: string; 20 | members: number; 21 | }; 22 | onClose: () => void; 23 | } 24 | 25 | function GroupInfo(props: GroupInfoProps) { 26 | const { visible, onClose, group } = props; 27 | 28 | const action = useAction(); 29 | const hasLinkman = useSelector( 30 | (state: State) => !!state.linkmans[group?._id as string], 31 | ); 32 | const [largerAvatar, toggleLargetAvatar] = useState(false); 33 | 34 | if (!group) { 35 | return null; 36 | } 37 | 38 | async function handleJoinGroup() { 39 | onClose(); 40 | 41 | if (!group) { 42 | return; 43 | } 44 | const groupRes = await joinGroup(group._id); 45 | if (groupRes) { 46 | groupRes.type = 'group'; 47 | action.addLinkman(groupRes, true); 48 | 49 | const messages = await getLinkmanHistoryMessages(group._id, 0); 50 | if (messages) { 51 | action.addLinkmanHistoryMessages(group._id, messages); 52 | } 53 | } 54 | } 55 | 56 | function handleFocusGroup() { 57 | onClose(); 58 | 59 | if (!group) { 60 | return; 61 | } 62 | action.setFocus(group._id); 63 | } 64 | 65 | return ( 66 | 71 |
72 |
73 | toggleLargetAvatar(true)} 77 | onMouseLeave={() => toggleLargetAvatar(false)} 78 | /> 79 | 群组头像 86 |

{group.name}

87 |
88 |
89 |
90 |

成员:

91 |
{group.members}人
92 |
93 | {hasLinkman ? ( 94 | 95 | ) : ( 96 | 97 | )} 98 |
99 |
100 |
101 | ); 102 | } 103 | 104 | export default GroupInfo; 105 | -------------------------------------------------------------------------------- /packages/web/src/state/action.ts: -------------------------------------------------------------------------------- 1 | import { Group, Friend, Message, Linkman } from './reducer'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export enum ActionTypes { 5 | /** 设置游客信息 */ 6 | SetGuest = 'SetGuest', 7 | /** 设置用户信息 */ 8 | SetUser = 'SetUser', 9 | /** 更新用户信息 */ 10 | UpdateUserInfo = 'UpdateUserInfo', 11 | /** 更新客户端状态 */ 12 | SetStatus = 'SetStatus', 13 | /** 退出登录 */ 14 | Logout = 'Logout', 15 | /** 设置用户头像 */ 16 | SetAvatar = 'SetAvatar', 17 | /** 添加新联系人 */ 18 | AddLinkman = 'AddLinkman', 19 | /** 移除指定联系人 */ 20 | RemoveLinkman = 'RemoveLinkman', 21 | /** 设置聚焦的联系人 */ 22 | SetFocus = 'SetFocus', 23 | /** 设置各联系人历史消息 */ 24 | SetLinkmansLastMessages = 'SetLinkmansLastMessages', 25 | /** 添加联系人历史消息 */ 26 | AddLinkmanHistoryMessages = 'AddLinkmanHistoryMessages', 27 | /** 添加联系人新消息 */ 28 | AddLinkmanMessage = 'AddLinkmanMessage', 29 | /** 设置联系人指定属性值 */ 30 | SetLinkmanProperty = 'SetLinkmanProperty', 31 | /** 更新消息 */ 32 | UpdateMessage = 'UpdateMessage', 33 | /** 删除消息 */ 34 | DeleteMessage = 'DeleteMessage', 35 | /** socket连接成功 */ 36 | Connect = 'Connect', 37 | /** socket断开连接 */ 38 | Disconnect = 'Disconnect', 39 | /** Aliyun OSS ready */ 40 | Ready = 'Ready', 41 | } 42 | 43 | export type SetGuestPayload = Group; 44 | 45 | export type SetUserPayload = { 46 | _id: string; 47 | username: string; 48 | avatar: string; 49 | tag: string; 50 | email: string; 51 | level: number; 52 | signature: string; 53 | pushToken: string; 54 | groups: Group[]; 55 | friends: Friend[]; 56 | isAdmin: boolean; 57 | }; 58 | 59 | export type UpdateUserInfoPayload = Object; 60 | 61 | export interface SetStatusPayload { 62 | key: string; 63 | value: any; 64 | } 65 | 66 | export type SetAvatarPayload = string; 67 | 68 | export interface AddLinkmanPayload { 69 | linkman: Linkman; 70 | focus: boolean; 71 | } 72 | 73 | export type SetFocusPayload = string; 74 | 75 | export interface SetLinkmansLastMessagesPayload { 76 | [linkmanId: string]: { 77 | messages: Message[]; 78 | unread: number; 79 | }; 80 | } 81 | 82 | export interface AddLinkmanHistoryMessagesPayload { 83 | linkmanId: string; 84 | messages: Message[]; 85 | } 86 | 87 | export interface AddLinkmanMessagePayload { 88 | linkmanId: string; 89 | message: Message; 90 | } 91 | 92 | export interface SetLinkmanPropertyPayload { 93 | linkmanId: string; 94 | key: string; 95 | value: any; 96 | } 97 | 98 | export type RemoveLinkmanPayload = string; 99 | 100 | export interface UpdateMessagePayload { 101 | linkmanId: string; 102 | messageId: string; 103 | value: any; 104 | } 105 | 106 | export interface DeleteMessagePayload { 107 | linkmanId: string; 108 | messageId: string; 109 | shouldDelete: boolean; 110 | } 111 | 112 | export interface Action { 113 | type: ActionTypes; 114 | payload: 115 | | SetUserPayload 116 | | UpdateUserInfoPayload 117 | | SetGuestPayload 118 | | SetStatusPayload 119 | | SetAvatarPayload 120 | | AddLinkmanPayload 121 | | SetFocusPayload 122 | | AddLinkmanHistoryMessagesPayload 123 | | AddLinkmanMessagePayload 124 | | SetLinkmanPropertyPayload 125 | | RemoveLinkmanPayload 126 | | SetLinkmansLastMessagesPayload 127 | | UpdateMessagePayload 128 | | DeleteMessagePayload; 129 | } 130 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bulita/web", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "dev:web": "cross-env NODE_ENV=development DOTENV_CONFIG_PATH=../../.env webpack serve --config build/webpack.dev.js", 8 | "build:web": "rm -rf dist && cross-env NODE_ENV=production DOTENV_CONFIG_PATH=../../.env webpack --config build/webpack.prod.js && cp -r -f dist/bulita/* ../server/public" 9 | }, 10 | "dependencies": { 11 | "@bulita/assets": "^1.0.0", 12 | "@bulita/config": "^1.0.0", 13 | "@bulita/utils": "^1.0.0", 14 | "@loadable/component": "^5.15.0", 15 | "@testing-library/jest-dom": "^4.2.4", 16 | "@testing-library/react": "^12.0.0", 17 | "ali-oss": "^6.16.0", 18 | "axios": "^0.21.1", 19 | "brace": "^0.11.1", 20 | "core-js": "^3.15.2", 21 | "cropperjs": "^1.5.5", 22 | "filesize": "^7.0.0", 23 | "linaria": "^2.1.0", 24 | "normalize.css": "^8.0.1", 25 | "platform": "^1.3.6", 26 | "prismjs": "^1.24.1", 27 | "pure-render-decorator": "^1.2.1", 28 | "qrcode.react": "^1.0.1", 29 | "rc-dialog": "^8.5.2", 30 | "rc-dropdown": "^3.2.0", 31 | "rc-menu": "^9.0.12", 32 | "rc-notification": "^3.3.1", 33 | "rc-progress": "^2.5.2", 34 | "rc-select": "^9.2.1", 35 | "rc-tabs": "^9.6.4", 36 | "rc-tooltip": "^5.1.1", 37 | "react": "^17.0.2", 38 | "react-ace": "^9.4.1", 39 | "react-color": "^2.19.3", 40 | "react-copy-to-clipboard": "^5.0.3", 41 | "react-cropper": "^1.3.0", 42 | "react-dom": "^17.0.2", 43 | "react-loading": "^2.0.3", 44 | "react-radio-buttons": "^1.2.2", 45 | "react-redux": "^7.2.4", 46 | "react-switch": "^6.0.0", 47 | "react-viewer": "^3.2.2", 48 | "redux": "^4.1.0", 49 | "regenerator-runtime": "^0.13.7", 50 | "socket.io-client": "^4.1.3", 51 | "wpk-reporter": "^0.9.3" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.14.6", 55 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 56 | "@babel/preset-env": "^7.14.7", 57 | "@babel/preset-react": "^7.14.5", 58 | "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", 59 | "@testing-library/jest-dom": "^5.14.1", 60 | "@types/ali-oss": "^6.0.10", 61 | "@types/loadable__component": "^5.13.4", 62 | "@types/platform": "^1.3.4", 63 | "@types/prismjs": "^1.16.6", 64 | "@types/pure-render-decorator": "^0.2.28", 65 | "@types/qrcode.react": "^1.0.2", 66 | "@types/react": "^17.0.14", 67 | "@types/react-color": "^3.0.5", 68 | "@types/react-copy-to-clipboard": "^5.0.1", 69 | "@types/react-cropper": "^0.10.7", 70 | "@types/react-dom": "^17.0.9", 71 | "@types/react-redux": "^7.1.18", 72 | "@types/redux": "^3.6.0", 73 | "babel-loader": "^8.2.2", 74 | "babel-plugin-dynamic-import-node": "^2.3.3", 75 | "babel-plugin-prismjs": "^2.1.0", 76 | "clean-webpack-plugin": "^4.0.0-alpha.0", 77 | "cross-env": "^7.0.3", 78 | "css-loader": "^5.0.0", 79 | "dotenv-webpack": "^7.0.3", 80 | "file-loader": "^6.2.0", 81 | "html-webpack-plugin": "^5.3.2", 82 | "less": "^3.12.2", 83 | "less-loader": "^7.0.2", 84 | "less-plugin-autoprefix": "^2.0.0", 85 | "react-refresh": "^0.10.0", 86 | "script-ext-html-webpack-plugin": "^2.1.5", 87 | "style-loader": "^2.0.0", 88 | "terser-webpack-plugin": "^5.1.4", 89 | "ts-loader": "^9.2.3", 90 | "url-loader": "^4.1.1", 91 | "webpack": "^5.45.1", 92 | "webpack-cli": "^4.7.2", 93 | "webpack-dev-server": "^3.11.2", 94 | "webpack-merge": "^5.8.0", 95 | "webpackbar": "^5.0.0-3", 96 | "workbox-webpack-plugin": "^6.1.5" 97 | } 98 | } 99 | --------------------------------------------------------------------------------