From cabc23ae77f46182eae55f5cab0a7a3ed8189e72 Mon Sep 17 00:00:00 2001
From: zhaoyingbo <zhaoyingbo@xiaomi.com>
Date: Mon, 4 Mar 2024 12:01:14 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=A0=B9=E6=8D=AE?=
 =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=BB=84=E8=BD=AC=E5=8F=91=E6=B6=88=E6=81=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .devcontainer/devcontainer.json |   5 +-
 .gitea/workflows/cicd.yaml      |  13 +-
 bun.lockb                       | Bin 2892 -> 5293 bytes
 db/index.ts                     |   9 +
 db/messageGroup/index.ts        |  13 +
 db/messageGroup/typings.d.ts    |  14 +
 db/pbClient.ts                  |   5 +
 db/tenantAccessToken/index.ts   |  28 ++
 docker-compose.yml              |   6 +-
 index.ts                        |   9 +-
 package.json                    |   2 +
 routes/bot/eventMsg.ts          | 108 ++++++++
 routes/bot/index.ts             |   4 +
 routes/message/index.ts         |  82 ++++++
 routes/message/readme.md        |   1 +
 schedule/accessToken.ts         |  20 ++
 schedule/index.ts               |   9 +
 test.ts                         |   5 +
 typings.d.ts                    | 445 ++++++++++++++++++++++++++++++++
 utils/pbTools.ts                |  11 +
 utils/sendMsg.ts                |  47 ++++
 21 files changed, 824 insertions(+), 12 deletions(-)
 create mode 100644 db/index.ts
 create mode 100644 db/messageGroup/index.ts
 create mode 100644 db/messageGroup/typings.d.ts
 create mode 100644 db/pbClient.ts
 create mode 100644 db/tenantAccessToken/index.ts
 create mode 100644 routes/bot/eventMsg.ts
 create mode 100644 routes/message/index.ts
 create mode 100644 routes/message/readme.md
 create mode 100644 schedule/accessToken.ts
 create mode 100644 schedule/index.ts
 create mode 100644 test.ts
 create mode 100644 typings.d.ts
 create mode 100644 utils/pbTools.ts
 create mode 100644 utils/sendMsg.ts

diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 041d7bb..f8bb848 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -7,9 +7,10 @@
   "customizations": {
     "vscode": {
       "settings": {
-        "files.autoSave": "off",
+        "files.autoSave": "afterDelay",
         "editor.guides.bracketPairs": true,
-        "editor.defaultFormatter": "esbenp.prettier-vscode"
+        "editor.defaultFormatter": "esbenp.prettier-vscode",
+        "editor.formatOnSave": true
       },
       "extensions": [
         "dbaeumer.vscode-eslint",
diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml
index aea4444..add2521 100644
--- a/.gitea/workflows/cicd.yaml
+++ b/.gitea/workflows/cicd.yaml
@@ -1,7 +1,7 @@
 name: Egg CI/CD
 on: [push]
 
-jobs: 
+jobs:
   build-image:
     runs-on: mi-server
     container: catthehacker/ubuntu:act-latest
@@ -22,7 +22,8 @@ jobs:
         uses: docker/build-push-action@v4
         with:
           push: true
-          tags: git.yingbo.im:333/zhaoyingbo/egg_server:latest
+          tags: git.yingbo.im:333/zhaoyingbo/egg_server:${{ github.sha }}
+
   deploy:
     needs: build-image
     runs-on: mi-server
@@ -40,7 +41,7 @@ jobs:
           key: ${{ secrets.SERVER_KEY }}
           port: ${{ secrets.SERVER_PORT }}
           source: docker-compose.yml
-          target: /home/yingbo/docker/egg_server
+          target: /home/deploy/docker/egg_server
       # 登录服务器,执行docker-compose命令
       - name: Login to the server and execute docker-compose command
         uses: appleboy/ssh-action@master
@@ -51,6 +52,6 @@ jobs:
           port: ${{ secrets.SERVER_PORT }}
           script: |
             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
-            docker compose -f /home/yingbo/docker/egg_server/docker-compose.yml pull
-            docker compose -f /home/yingbo/docker/egg_server/docker-compose.yml up -d
+            cd /home/deploy/docker/egg_server
+            sed -i "s/sha/${{ github.sha }}/g" docker-compose.yml
+            docker-compose up -d --force-recreate --no-deps egg_server
diff --git a/bun.lockb b/bun.lockb
index 5e250ba70a1b30a06a5c2775819ba1fd1862b062..e9c8673bc0c75828474c664a41c31806e9acffb4 100755
GIT binary patch
literal 5293
zcmeHLd2AGA6rb6OmZcnW(*W6e6<TKY=(cuAX&MPf5IM>b<S0zHv&+El%sMmM%UD<9
zQH@m*v;-t21#2)SXr<AJAd*-i#h@tBAW>0_9QubxB9d5qZ+2cg#U+_;H8JWVU*Gr5
z_x*nF?fbs>%{*?+amO{)T`9?KQLc7X#$_ioE}{fChNOtdg%mX^=v=kO>0lTpe#rYp
z?x@_z(P=w3M(%$7m$rkKb}X2)w0Y#@+OqQdK7Mu5XjlZ2l~hdTH9BFASJMJx>|~fk
zDfCc#K@SC;%`(hb(8EB(;zS1MA)q7iT1AF6lc0gI#Hftq^J>tz4jmW_dio7XJ?g0m
z4^Oz9x21iXClKg7?47-$EHA6Xvx)s-%8R8FAK3fteBTqT9T(0jdx}?mv^MkLgrk?=
zTqx`~me<{RwUy6q-Rxf3bVtXGz+k1M^Wx&|2k!qq{6XW$OSkOq+E&-H;iLp^4M;Gm
zD088$0>8082AW-7!tVymI>6%?vHit+0H6XMaS%$|LiSq$SYi5)9N6p#;Th<x20UyV
z?w@TT{A%bd0zCST9N6p#;a`DH81Oho-*2=7hBbtr42D+#9?u_~Q)YZW@tXj@7Vv`r
zmkkZI9U=Q?0e>&xDejmbc7*UVAz<Zz$BaeWkc+kj*OR}faSyJ|d>o^1w6~~njCN3C
z%urCHPjCkq&p{d7e;<GM@NNnk8vBccWs<yBYWDOyTe>RF4{18`^qe){EvS9JAt!NX
zKkX4O?Hj^bX+`^c`x^bvEtC#8>zh0Co5!#DCQV-4I}-NMLDI6|)O)VBAFGS5%l~Xv
zOa0JOG-*?lzt((Yn{z&G+}|*yYvpZ6zCLtz*Xd=aJIoh{O;$V`$@tQ_Kp>k@Ce8c#
z+=k4p%}*-jZJ&1>{xLgUf35k?o^YMXJ+mZ#PxjasX8wMh39cJcv(L7t$%W+aO%@!x
z<JV{QPSqAA$|qmm()H50GZ!w7zF?WR(<NTYH^i{VUGbYo%-(u_LCb3oZqJ&3*C&f>
zp0z_}Swp;(uLkeirvi`9U^?XI7oHUB=Z4yI+Ln*AVC-~>*LuIOZwSA=`_Rk7u1wT-
ze)(43lyfyrSAMcXW?4hLbdTT^JMTotxaQ0yS44exTVr_hhw}pA$1IrsbT5@PO3Jd-
z2<us)75|oJ^n0$St@qpY*8KnczZrpD=2!lHQ#(sEHONb{rVEh>PbCJwR29Wim(SzU
zq<T>aO>_BOLPQG7O31t<y00b$qlo$amC2Wd9|<7Nl6Zly6ksU`@O<LC65nh14njP_
z@x6gLiuW;^!TYn=<crzhMUN1Nc;rVr$cZ-47IGse-k+$^F6vz-ch+zifH+H5`HI%W
ziy;6-J^p)p1AsQ5C0cGzOv`^ntQ^5s6!IS4V^lvzr9%}-rXwQgqNexOMpVN%dOjAY
zx}qw`!B+TqFAtBY{-QbwW<0zPr>OFx3JGTXybnE~+Kg%_2kS|aF;u-#l?50tUw{~@
z->A-lnF3$~52%u(ip#;~84+R_s_m!-bFdACyswD&r@%D$zz48J^&iz~fGOq+d9No0
zhU_di_k?uc^Qwu_YEk89)#{=u3y~$dD#_tem$!G@ihH&#VKR*|KWpHG!VY^=Y_J#(
zuhm2qnDx4ts0CFiregtajLM9^Zds`kIW4$etcpj(K7$yP>7rgKXySTZk7?80?%2k#
z##iyRlAuH-UKaJ~et&^mP<1IJ1a(ENa|<!aEytqnsKlY_rEqhW0hio;UJuu&P2I;;
zVDB^LjDv^#K~<5tn4m%!B8nX5bSWw-aa~hXU992+RTb*EP+Sh`k|JwqJCm9Yyh=U6
z8RvCCKi~Rez>akY(S_6+5W6L6KyDdvem(#<#6ULANK$ke|FeZF;QjD#AbhFLG0^|{
zJQSfB%j&du+rmMHk{5%|_$6_<N(xHJBum?dl?04fhn#Ug6%Ht9J+}aG_w?adLShCi
bVX8?#CCnhzXkej6<C6CyQk(bgKTiDt=l?$I

delta 624
zcmZ3hc}8r4p62Ex+3QOUtxi9)V~n_bN=UyrocqRs30#M+tdNgl{rhdo9xetjV4D~&
zpOwc3;Xp{BOoJ?t38F=SG$)XL2BmKUX>K5YCL;p_50Dn_lWdXvp*4Ba3<FEcV+RbY
z-=v@I5PGe&`1Fj*`B|4TC+bJ_s%^Ek<jl7@_9}4J?#T{}9*pXfI~nbnniwXZVzy&?
z&oG&b#cXl|BL|Zs<76+O$Wq41UCd^a0~lE%nSs`UFbIHXCLm@7Vxa8|AUA_(b|8iU
zqSa6S%W`6JD~I=F0XE*rcR8FVC$Nc5zRK=B`2diw3*-kda<ILgU}!HMHrbI;Y;pr<
z%4P*FF-EQp(4acPHaU&QM(W>x2mm<%<ggD=X%_a$`*@7_Ks*De1ARb3N@^hWAa8)2
z&H}_BCxd8^?gpq*GmgnsylNns$@6(FB|s4Y3t%S3I0HQ+Lp?)=+YFNr@JYx#1F8bW
z5G=%*80#$bO!W*6Kni;JBvfEg1&acp0#iLBGd&ZA1`eQJAZXy2EXMD^wE@cA!ZkU8
rUjgco$qoFnllSq@n><Tcoh2_nC3W&H;poZA0+y4n3w2Ev6`275ia(Kp

diff --git a/db/index.ts b/db/index.ts
new file mode 100644
index 0000000..d8078b4
--- /dev/null
+++ b/db/index.ts
@@ -0,0 +1,9 @@
+import messageGroup from "./messageGroup";
+import tenantAccessToken from "./tenantAccessToken";
+
+const db = {
+  messageGroup,
+  tenantAccessToken,
+};
+
+export default db;
diff --git a/db/messageGroup/index.ts b/db/messageGroup/index.ts
new file mode 100644
index 0000000..c0e910f
--- /dev/null
+++ b/db/messageGroup/index.ts
@@ -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;
diff --git a/db/messageGroup/typings.d.ts b/db/messageGroup/typings.d.ts
new file mode 100644
index 0000000..eaecbe5
--- /dev/null
+++ b/db/messageGroup/typings.d.ts
@@ -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[];
+}
diff --git a/db/pbClient.ts b/db/pbClient.ts
new file mode 100644
index 0000000..4d7da78
--- /dev/null
+++ b/db/pbClient.ts
@@ -0,0 +1,5 @@
+import PocketBase from 'pocketbase';
+
+const pbClient = new PocketBase('https://eggpb.imoaix.cn')
+
+export default pbClient;
diff --git a/db/tenantAccessToken/index.ts b/db/tenantAccessToken/index.ts
new file mode 100644
index 0000000..342f327
--- /dev/null
+++ b/db/tenantAccessToken/index.ts
@@ -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;
diff --git a/docker-compose.yml b/docker-compose.yml
index 3c484ab..818f2de 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,9 +1,9 @@
 version: "3"
 
 services:
-  server:
-    image: git.yingbo.im:333/zhaoyingbo/egg_server:latest
+  egg_server:
+    image: git.yingbo.im:333/zhaoyingbo/egg_server:sha
     container_name: egg_server
     restart: always
     ports:
-      - 3000:3000
\ No newline at end of file
+      - 3003:3000
diff --git a/index.ts b/index.ts
index ea12ce2..a81b93a 100644
--- a/index.ts
+++ b/index.ts
@@ -1,4 +1,8 @@
 import { manageBotReq } from "./routes/bot";
+import { manageMessageReq } from "./routes/message";
+import { initSchedule } from "./schedule";
+
+initSchedule()
 
 Bun.serve({
   async fetch(req) {
@@ -7,7 +11,10 @@ Bun.serve({
     if (url.pathname === "/") return new Response("hello, glade to see you!");
     // 机器人
     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
 });
\ No newline at end of file
diff --git a/package.json b/package.json
index 83bba9a..bb54114 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
     "typescript": "^5.0.0"
   },
   "dependencies": {
+    "@types/node-schedule": "^2.1.6",
+    "node-schedule": "^2.1.1",
     "pocketbase": "^0.21.1"
   }
 }
\ No newline at end of file
diff --git a/routes/bot/eventMsg.ts b/routes/bot/eventMsg.ts
new file mode 100644
index 0000000..48e4b99
--- /dev/null
+++ b/routes/bot/eventMsg.ts
@@ -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
+  }
+}
\ No newline at end of file
diff --git a/routes/bot/index.ts b/routes/bot/index.ts
index 73d8701..48f5f4a 100644
--- a/routes/bot/index.ts
+++ b/routes/bot/index.ts
@@ -1,3 +1,5 @@
+import { manageEventMsg } from "./eventMsg"
+
 export const manageBotReq = async (req: Request) => {
   const body = await req.json() as any
   // 验证机器人
@@ -5,5 +7,7 @@ export const manageBotReq = async (req: Request) => {
     console.log("🚀 ~ manageBotReq ~ url_verification:")
     return Response.json({ challenge: body?.challenge })
   }
+  // 处理Event消息
+  if (await manageEventMsg(body)) return new Response("success")
   return new Response("hello, glade to see you!")
 }
\ No newline at end of file
diff --git a/routes/message/index.ts b/routes/message/index.ts
new file mode 100644
index 0000000..7c70e3a
--- /dev/null
+++ b/routes/message/index.ts
@@ -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,
+    });
+  }
+};
diff --git a/routes/message/readme.md b/routes/message/readme.md
new file mode 100644
index 0000000..aa1cf60
--- /dev/null
+++ b/routes/message/readme.md
@@ -0,0 +1 @@
+# 批量发送消息,给已经订阅的用户和群组发送消息
diff --git a/schedule/accessToken.ts b/schedule/accessToken.ts
new file mode 100644
index 0000000..39a2a63
--- /dev/null
+++ b/schedule/accessToken.ts
@@ -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
+}
\ No newline at end of file
diff --git a/schedule/index.ts b/schedule/index.ts
new file mode 100644
index 0000000..d1c5945
--- /dev/null
+++ b/schedule/index.ts
@@ -0,0 +1,9 @@
+import { resetAccessToken } from "./accessToken";
+import schedule from 'node-schedule'
+
+export const initSchedule = async () => {
+  // 定时任务,每15分钟刷新一次token
+  schedule.scheduleJob('*/15 * * * *', resetAccessToken);
+  // 立即执行一次
+  resetAccessToken()
+}
diff --git a/test.ts b/test.ts
new file mode 100644
index 0000000..5c3fa86
--- /dev/null
+++ b/test.ts
@@ -0,0 +1,5 @@
+console.log(
+  JSON.stringify({
+    text: "hello",
+  })
+);
diff --git a/typings.d.ts b/typings.d.ts
new file mode 100644
index 0000000..a500c3a
--- /dev/null
+++ b/typings.d.ts
@@ -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} 交互结果,会读卡片按钮绑定的变量text,如果没有则是绑定的result对应的 已确认、已取消、已延迟
+     * ${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 text、post、image、file、audio、media、sticker、interactive、share_chat、share_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";
\ No newline at end of file
diff --git a/utils/pbTools.ts b/utils/pbTools.ts
new file mode 100644
index 0000000..77ed703
--- /dev/null
+++ b/utils/pbTools.ts
@@ -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;
+  }
+}
diff --git a/utils/sendMsg.ts b/utils/sendMsg.ts
new file mode 100644
index 0000000..eb603a6
--- /dev/null
+++ b/utils/sendMsg.ts
@@ -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 消息接收者的ID,ID类型应与查询参数receive_id_type 对应
+ * @param {MsgType} msg_type 消息类型 包括:text、post、image、file、audio、media、sticker、interactive、share_chat、share_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: '这是测试消息,不要回复'}))