├── 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 |
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 |
41 | {children}
42 |
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(text );
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 |
20 | text
21 | ,
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(text );
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 | 
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 |
45 | 创建
46 |
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 | 
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 |
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 |
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 |
62 | 一键注册
63 |
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 |
81 | 登录
82 |
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 ` `;
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 ` `;
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 |
--------------------------------------------------------------------------------