feat: 支持根据用户组转发消息
Some checks are pending
Egg CI/CD / build-image (push) Waiting to run
Egg CI/CD / deploy (push) Blocked by required conditions

This commit is contained in:
zhaoyingbo 2024-03-04 12:01:14 +00:00
parent c1a4890eec
commit cabc23ae77
21 changed files with 824 additions and 12 deletions

View File

@ -7,9 +7,10 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": { "settings": {
"files.autoSave": "off", "files.autoSave": "afterDelay",
"editor.guides.bracketPairs": true, "editor.guides.bracketPairs": true,
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}, },
"extensions": [ "extensions": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",

View File

@ -1,7 +1,7 @@
name: Egg CI/CD name: Egg CI/CD
on: [push] on: [push]
jobs: jobs:
build-image: build-image:
runs-on: mi-server runs-on: mi-server
container: catthehacker/ubuntu:act-latest container: catthehacker/ubuntu:act-latest
@ -22,7 +22,8 @@ jobs:
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
push: true push: true
tags: git.yingbo.im:333/zhaoyingbo/egg_server:latest tags: git.yingbo.im:333/zhaoyingbo/egg_server:${{ github.sha }}
deploy: deploy:
needs: build-image needs: build-image
runs-on: mi-server runs-on: mi-server
@ -40,7 +41,7 @@ jobs:
key: ${{ secrets.SERVER_KEY }} key: ${{ secrets.SERVER_KEY }}
port: ${{ secrets.SERVER_PORT }} port: ${{ secrets.SERVER_PORT }}
source: docker-compose.yml source: docker-compose.yml
target: /home/yingbo/docker/egg_server target: /home/deploy/docker/egg_server
# 登录服务器执行docker-compose命令 # 登录服务器执行docker-compose命令
- name: Login to the server and execute docker-compose command - name: Login to the server and execute docker-compose command
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master
@ -51,6 +52,6 @@ jobs:
port: ${{ secrets.SERVER_PORT }} port: ${{ secrets.SERVER_PORT }}
script: | script: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} git.yingbo.im:333 docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} git.yingbo.im:333
docker compose -f /home/yingbo/docker/egg_server/docker-compose.yml down cd /home/deploy/docker/egg_server
docker compose -f /home/yingbo/docker/egg_server/docker-compose.yml pull sed -i "s/sha/${{ github.sha }}/g" docker-compose.yml
docker compose -f /home/yingbo/docker/egg_server/docker-compose.yml up -d docker-compose up -d --force-recreate --no-deps egg_server

BIN
bun.lockb

Binary file not shown.

9
db/index.ts Normal file
View File

@ -0,0 +1,9 @@
import messageGroup from "./messageGroup";
import tenantAccessToken from "./tenantAccessToken";
const db = {
messageGroup,
tenantAccessToken,
};
export default db;

13
db/messageGroup/index.ts Normal file
View File

@ -0,0 +1,13 @@
import { managePb404 } from "../../utils/pbTools";
import pbClient from "../pbClient";
const getOne = (groupId: string) =>
managePb404(
async () => await pbClient.collection("message_group").getOne(groupId)
);
const messageGroup = {
getOne,
};
export default messageGroup;

14
db/messageGroup/typings.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
interface PBMessageGroup {
collectionId: string;
collectionName: string;
updated: string;
created: string;
desc: string;
id: string;
name: string;
email?: string[];
chat_id?: string[];
open_id?: string[];
union_id?: string[];
user_id?: string[];
}

5
db/pbClient.ts Normal file
View File

@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
const pbClient = new PocketBase('https://eggpb.imoaix.cn')
export default pbClient;

View File

@ -0,0 +1,28 @@
import pbClient from "../pbClient";
/**
* token
* @param {string} value token
*/
const update = async (value: string) => {
await pbClient.collection("config").update("ugel8f0cpk0rut6", { value });
console.log("reset access token success", value);
};
/**
* token
* @returns {string} token
*/
const get = async () => {
const { value } = await pbClient
.collection("config")
.getOne("ugel8f0cpk0rut6");
return value as string;
};
const tenantAccessToken = {
update,
get,
};
export default tenantAccessToken;

View File

@ -1,9 +1,9 @@
version: "3" version: "3"
services: services:
server: egg_server:
image: git.yingbo.im:333/zhaoyingbo/egg_server:latest image: git.yingbo.im:333/zhaoyingbo/egg_server:sha
container_name: egg_server container_name: egg_server
restart: always restart: always
ports: ports:
- 3000:3000 - 3003:3000

View File

@ -1,4 +1,8 @@
import { manageBotReq } from "./routes/bot"; import { manageBotReq } from "./routes/bot";
import { manageMessageReq } from "./routes/message";
import { initSchedule } from "./schedule";
initSchedule()
Bun.serve({ Bun.serve({
async fetch(req) { async fetch(req) {
@ -7,7 +11,10 @@ Bun.serve({
if (url.pathname === "/") return new Response("hello, glade to see you!"); if (url.pathname === "/") return new Response("hello, glade to see you!");
// 机器人 // 机器人
if (url.pathname === '/bot') return await manageBotReq(req); if (url.pathname === '/bot') return await manageBotReq(req);
return Response.json({a: 'b'}); // 消息发送
if (url.pathname === '/message') return await manageMessageReq(req);
// 其他
return new Response('OK')
}, },
port: 3000 port: 3000
}); });

View File

@ -12,6 +12,8 @@
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@types/node-schedule": "^2.1.6",
"node-schedule": "^2.1.1",
"pocketbase": "^0.21.1" "pocketbase": "^0.21.1"
} }
} }

108
routes/bot/eventMsg.ts Normal file
View File

@ -0,0 +1,108 @@
import { sendMsg } from "../../utils/sendMsg";
/**
*
* @param {LarkMessageEvent} body
*/
const isEventMsg = (body: LarkMessageEvent) => {
return body?.header?.event_type === "im.message.receive_v1";
}
/**
*
* @param {LarkMessageEvent} body
* @returns
*/
const getMsgType = (body: LarkMessageEvent) => {
return body?.event?.message?.message_type
}
/**
* Id
* @param {LarkMessageEvent} body
* @returns
*/
const getChatId = (body: LarkMessageEvent) => {
return body?.event?.message?.chat_id
}
/**
*
* @param {LarkMessageEvent} body
* @returns {string}
*/
const getMsgText = (body: LarkMessageEvent) => {
// TODO: 如果之后想支持单独提醒,这里需要做模板解析
try {
const { text } = JSON.parse(body?.event?.message?.content)
// 去掉@_user_1相关的内容例如 '@_user_1 测试' -> '测试'
const textWithoutAt = text.replace(/@_user_\d+/g, '')
// 去除空格和换行
const textWithoutSpace = textWithoutAt.replace(/[\s\n]/g, '')
return textWithoutSpace
}
catch (e) {
return ''
}
}
/**
*
* @param {LarkMessageEvent} body
* @returns {boolean}
*/
const filterIllegalMsg = (body: LarkMessageEvent) => {
const chatId = getChatId(body)
if (!chatId) return true
const msgType = getMsgType(body)
if (msgType === 'sticker') {
const content = body?.event?.message?.content
sendMsg('chat_id', chatId, 'sticker', content)
return true
}
if (msgType !== 'text') {
const textList = [
'仅支持普通文本内容[黑脸]',
'唔...我只能处理普通文本哦[泣不成声]',
'噢!这似乎是个非普通文本[看]',
'哇!这是什么东东?我只懂普通文本啦![可爱]',
'只能处理普通文本内容哦[捂脸]',
]
const content = JSON.stringify({ text: textList[Math.floor(Math.random() * textList.length)] })
sendMsg('chat_id', chatId, 'text', content)
return true
}
return false
}
/**
* info指令
* @param {LarkMessageEvent} body
* @returns {boolean} info指令
*/
const filterGetInfoCommand = (body: LarkMessageEvent) => {
const chatId = getChatId(body)
const text = getMsgText(body)
if (text !== 'info') return false
const content = JSON.stringify({ text: JSON.stringify(body)})
sendMsg('chat_id', chatId, 'text', content)
return true
}
export const manageEventMsg = async (body: LarkMessageEvent) => {
// 过滤非Event消息
if (!isEventMsg(body)) {
return false;
}
// 过滤非法消息
if (filterIllegalMsg(body)) {
return true
}
// 过滤info指令
if (filterGetInfoCommand(body)) {
return true
}
}

View File

@ -1,3 +1,5 @@
import { manageEventMsg } from "./eventMsg"
export const manageBotReq = async (req: Request) => { export const manageBotReq = async (req: Request) => {
const body = await req.json() as any const body = await req.json() as any
// 验证机器人 // 验证机器人
@ -5,5 +7,7 @@ export const manageBotReq = async (req: Request) => {
console.log("🚀 ~ manageBotReq ~ url_verification:") console.log("🚀 ~ manageBotReq ~ url_verification:")
return Response.json({ challenge: body?.challenge }) return Response.json({ challenge: body?.challenge })
} }
// 处理Event消息
if (await manageEventMsg(body)) return new Response("success")
return new Response("hello, glade to see you!") return new Response("hello, glade to see you!")
} }

82
routes/message/index.ts Normal file
View File

@ -0,0 +1,82 @@
import db from "../../db";
import { sendMsg } from "../../utils/sendMsg";
interface MessageReqJson {
group_id: string;
msg_type: MsgType;
content: string;
}
const validateMessageReq = (body: MessageReqJson) => {
if (!body.group_id) {
return new Response("group_id is required");
}
if (!body.msg_type) {
return new Response("msg_type is required");
}
if (!body.content) {
return new Response("content is required");
}
return false;
};
export const manageMessageReq = async (req: Request) => {
const body = (await req.json()) as MessageReqJson;
// 校验参数
const validateRes = validateMessageReq(body);
if (validateRes) {
return validateRes;
}
// 获取所有接收者
const group = (await db.messageGroup.getOne(body.group_id)) as PBMessageGroup;
if (!group) {
return new Response("group not found");
}
const { chat_id, open_id, union_id, user_id, email } = group;
// 遍历所有id发送消息保存所有对应的messageId
const sendRes = {
chat_id: {} as Record<string, any>,
open_id: {} as Record<string, any>,
union_id: {} as Record<string, any>,
user_id: {} as Record<string, any>,
email: {} as Record<string, any>,
};
// 发送消息列表
const sendList = [] as Promise<any>[];
// 构造发送消息函数
const makeSendFunc = (receive_id_type: ReceiveIDType) => {
return (receive_id: string) => {
sendList.push(
sendMsg(receive_id_type, receive_id, body.msg_type, body.content).then(
(res) => {
sendRes[receive_id_type][receive_id] = res;
}
)
);
};
};
// 发送消息
if (chat_id) chat_id.map(makeSendFunc("chat_id"));
if (open_id) open_id.map(makeSendFunc("open_id"));
if (union_id) union_id.map(makeSendFunc("union_id"));
if (user_id) user_id.map(makeSendFunc("user_id"));
if (email) email.map(makeSendFunc("email"));
try {
await Promise.all(sendList);
return Response.json({
code: 200,
msg: "ok",
data: sendRes,
});
} catch {
return Response.json({
code: 400,
msg: "send msg failed",
data: sendRes,
});
}
};

1
routes/message/readme.md Normal file
View File

@ -0,0 +1 @@
# 批量发送消息,给已经订阅的用户和群组发送消息

20
schedule/accessToken.ts Normal file
View File

@ -0,0 +1,20 @@
import db from "../db"
export const resetAccessToken = async () => {
const URL = 'https://open.f.mioffice.cn/open-apis/auth/v3/tenant_access_token/internal'
const app_id = 'cli_a1eff35b43b89063'
const app_secret = 'IFSl8ig5DMwMnFjwPiljCfoEWlgRwDxW'
const res = await fetch(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
app_id,
app_secret
})
})
const { tenant_access_token } = await res.json() as any
await db.tenantAccessToken.update(tenant_access_token)
return tenant_access_token
}

9
schedule/index.ts Normal file
View File

@ -0,0 +1,9 @@
import { resetAccessToken } from "./accessToken";
import schedule from 'node-schedule'
export const initSchedule = async () => {
// 定时任务每15分钟刷新一次token
schedule.scheduleJob('*/15 * * * *', resetAccessToken);
// 立即执行一次
resetAccessToken()
}

5
test.ts Normal file
View File

@ -0,0 +1,5 @@
console.log(
JSON.stringify({
text: "hello",
})
);

445
typings.d.ts vendored Normal file
View File

@ -0,0 +1,445 @@
/**
*
*/
interface User {
/**
* id
*/
id: string;
/**
*
* @example zhaoyingbo
*/
userId: string;
/**
* open_id
*/
openId: string;
/**
*
*/
remindList: string[];
}
/**
*
*/
interface Remind {
/**
* id
*/
id: string;
/**
* id
*/
owner: string;
/**
* Id
*/
messageId: string;
/**
*
*/
subscriberType: "open_id" | "user_id" | "union_id" | "email" | "chat_id";
/**
* Id
*/
subscriberId: string;
/**
*
*/
needReply: boolean;
/**
*
*/
delayTime: number;
/**
*
*/
cardInfo: {
/**
*
*/
title: string;
/**
* key
*/
imageKey?: string;
/**
*
*/
content?: string;
/**
*
*/
confirmText?: string;
/**
*
*/
cancelText?: string;
/**
*
*/
delayText?: string;
} | null;
/**
*
*/
templateInfo: {
/**
* ID
* ${owner}
* ${remindTime}
*/
pendingTemplateId: string;
/**
* ID
* ${owner}
* ${remindTime}
* ${result} textresult对应的
* ${interactTime}
*/
interactedTemplateId: string;
/**
* ID
*/
confirmedTemplateId: string;
/**
* ID
*/
cancelededTemplateId: string;
/**
* ID
*/
delayedTemplateId: string;
} | null;
/**
*
*/
remindTimes: RemindTime[];
/**
*
*/
enabled: boolean;
/**
* yyyy-MM-dd HH:mm
*/
nextRemindTime: string;
/**
*
*/
nextRemindTimeCHS: string;
}
/**
*
*
*/
interface RemindTime {
/**
*
* single: 一次性
* daily: 每天
* weekly: 每周
* monthly: 每月
* yearly: 每年
* workday: 工作日
* holiday: 节假日
*/
frequency:
| "single"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "workday"
| "holiday";
/**
* 格式为HH:mm single类型时仅作展示用yyyy-MM-dd HH:mm
*/
time: string;
/**
* [1-7]frequency为weekly时有效
*/
daysOfWeek: number[];
/**
* [1-31]frequency为monthly时有效
*/
daysOfMonth: number[];
/**
* frequency为 yearly MM-dd
*/
dayOfYear: string;
}
/**
*
*
*/
interface RemindRecord {
/**
* Id
*/
id: string;
/**
* Id
*/
remindId: string;
/**
* Id
*/
messageId: string;
/**
*
* pending: 待确认
* delay: 已延迟
* confirmed: 已确认
* canceled: 已取消
*/
status: "pending" | "delayed" | "confirmed" | "canceled";
/**
* yyyy-MM-dd HH:mm
*/
remindTime: string;
/**
* yyyy-MM-dd HH:mm
*/
interactTime: string;
/**
* 07:00
*/
result: object;
}
/**
*
*/
interface Header {
/**
* ID
* @example 0f8ab23b60993cf8dd15c8cde4d7b0f5
*/
event_id: string;
/**
* token
* @example tV9djUKSjzVnekV7xTg2Od06NFTcsBnj
*/
token: string;
/**
*
* @example 1693565712117
*/
create_time: string;
/**
*
* @example im.message.receive_v1
*/
event_type: string;
/**
* tenant_key
* @example 2ee61fe50f4f1657
*/
tenant_key: string;
/**
* app_id
* @example cli_a1eff35b43b89063
*/
app_id: string;
}
/**
* ID信息
*/
interface UserIdInfo {
/**
*
* @example ou_032f507d08f9a7f28b042fcd086daef5
*/
open_id: string;
/**
*
* @example on_7111660fddd8302ce47bf1999147c011
*/
union_id: string;
/**
*
* @example zhaoyingbo
*/
user_id: string;
}
/**
* AT的人的信息
*/
interface Mention {
/**
* ID信息
*/
id: UserIdInfo;
/**
*
* @example "@_user_1"
*/
key: string;
/**
*
* @example
*/
name: string;
/**
* ID
* @example 2ee61fe50f4f1657
*/
tenant_key: string;
}
/**
*
*/
interface Message {
/**
* ID
* @example oc_433b1cb7a9dbb7ebe70a4e1a59cb8bb1
*/
chat_id: string;
/**
*
* @example group | p2p
*/
chat_type: string;
/**
* JSON字符串文本内容
* @example "{\"text\":\"@_user_1 测试\"}"
*/
content: string;
/**
*
* @example 1693565711996
*/
create_time: string;
/**
*
*/
mentions?: Mention[];
/**
* ID
* @example om_038fc0eceed6224a1abc1cdaa4266405
*/
message_id: string;
/**
*
* @example textpostimagefileaudiomediastickerinteractiveshare_chatshare_user
*/
message_type: string;
}
/**
*
*/
interface Sender {
/**
* id
*/
sender_id: UserIdInfo;
/**
*
* @example user
*/
sender_type: string;
/**
* ID
* @example 2ee61fe50f4f1657
*/
tenant_key: string;
}
/**
*
*/
interface Event {
message: Message;
sender: Sender;
}
/**
*
*/
interface LarkMessageEvent {
/**
*
* @example 2.0
*/
schema: string;
/**
*
*/
header: Header;
/**
*
*/
event: Event;
}
/**
* Action信息
*/
interface LarkUserAction {
/**
* open_id
*/
open_id: string;
/**
*
* @example zhaoyingbo
*/
user_id: string;
/**
* ID
* @example om_038fc0eceed6224a1abc1cdaa4266405
*/
open_message_id: string;
/**
* ID
* @example oc_433b1cb7a9dbb7ebe70a4e1a59cb8bb1
*/
open_chat_id: string;
/**
* ID
* @example 2ee61fe50f4f1657
*/
tenant_key: string;
/**
* token
* @example tV9djUKSjzVnekV7xTg2Od06NFTcsBnj
*/
token: string;
/**
*
*/
action: {
/**
*
*/
value: any;
/**
*
* @example picker_datetime
*/
tag: string;
/**
*
* @example 2023-09-03 10:35 +0800
*/
option: string;
/**
*
*/
timezone: string;
};
}
type ReceiveIDType = "open_id" | "user_id" | "union_id" | "email" | "chat_id";
type MsgType = "text" | "post" | "image" | "file" | "audio" | "media" | "sticker" | "interactive" | "share_chat" | "share_user";

11
utils/pbTools.ts Normal file
View File

@ -0,0 +1,11 @@
export const managePb404 = async (dbFunc: Function) => {
try {
return await dbFunc()
} catch (err: any) {
console.log("🚀 ~ manage404 ~ err:", err)
// 没有这个提醒就返回空
if (err?.message === "The requested resource wasn't found.") {
return null
} else throw err;
}
}

47
utils/sendMsg.ts Normal file
View File

@ -0,0 +1,47 @@
import db from "../db";
/**
*
* @param {ReceiveIDType} receive_id_type id类型 open_id/user_id/union_id/email/chat_id
* @param {string} receive_id IDID类型应与查询参数receive_id_type
* @param {MsgType} msg_type textpostimagefileaudiomediastickerinteractiveshare_chatshare_user
* @param {string} content JSON结构序列化后的字符串msg_type对应不同内容
* @returns {string} id
*/
export const sendMsg = async (
receive_id_type: ReceiveIDType,
receive_id: string,
msg_type: MsgType,
content: string
) => {
const URL = `https://open.f.mioffice.cn/open-apis/im/v1/messages?receive_id_type=${receive_id_type}`;
const tenant_access_token = await db.tenantAccessToken.get();
const header = {
"Content-Type": "application/json",
Authorization: `Bearer ${tenant_access_token}`,
};
const body = { receive_id, msg_type, content };
const res = await fetch(URL, {
method: "POST",
headers: header,
body: JSON.stringify(body),
});
const data = (await res.json()) as any;
if (data.code !== 0) {
console.log("sendMsg error", data);
return {
code: data.code,
msg: data.msg,
};
}
console.log("sendMsg success", data);
return {
code: 0,
msg: "success",
data: {
message_id: data.data.message_id,
},
};
};
// sendMsg('user_id', 'liuke9', 'text', JSON.stringify({text: '这是测试消息,不要回复'}))