因老是忘记排期,我开发了一个周期性提醒的小工具

1,219 阅读6分钟

大家好,我是程序员凌览。

每周都会为下一周的工作制定计划,也就是自我安排下周的任务。但由于缺乏提醒,我总是忘记执行,导致KPI受到影响。

为了解决这个提醒问题,我意识到肯定还有其他人和我一样面临这样的需求。因此,我计划开发一个网页应用,让用户能够通过简单的点击操作来设置钉钉机器人,实现周期性的提醒功能。 image.png

这个小工具的后端模块已经开发完成。然而,在与同事讨论这个工具时,我意外得知钉钉群的自定义机器人已经具备了类似的功能。

诺,就是这个:

image.png

个人产品很难与官方成熟的解决方案竞争。因此,这个产品的构想在刚刚萌芽时就夭折了。

接下来,我将分享这个小工具是如何一步步完成的。

钉钉自定义机器人加签处理

根据钉钉自定义机器人官方文档,创建自定义机器人后,我们需要在安全设置中选择“加签”选项。这意味着后端需要对发送给钉钉服务器的请求进行签名处理,以确保数据的安全性和完整性。

image.png

在Node.js中,我们可以使用内置的crypto模块来实现这一功能,无需额外下载任何第三方库。以下是使用Node.js的crypto模块进行签名处理的基本步骤:

const crypto = require('crypto');
const querystring = require('querystring');
const qs = require('qs');

const getUrl = ({ access_token, secret }) => {
		//钉钉链接
    const baseURL = 'https://oapi.dingtalk.com/robot/send';
    const timestamp = Math.round(Date.now()).toString();
    const stringToSign = `${timestamp}\n${secret}`;
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(stringToSign);
    const hmacCode = hmac.digest();
    // 将签名编码为 Base64 并进行 URL 编码
    const sign = querystring.escape(Buffer.from(hmacCode).toString('base64'));
    const query = qs.stringify({
        access_token,
        timestamp,
        sign
    })
    return `${baseURL}?${query}`;
}

其中access_tokensecret 由钉钉机器人提供,复制出来即可:

image.png

自定义机器人消息数据处理

钉钉自定义机器人支持多种消息类型,用于发送不同格式的消息。为了简化操作,我们将自定义机器人需要发送的消息模板数据存储在一个JSON文件中,该文件位于:

database/db.json

{
  "robot": [
    {
      "access_token": "xx",
      "secret": "xx",
      "msgtype": "markdown",
      "cron": "0 0 16-21 * * 5-7",
      "content": {
        "markdown": {
          "title": "排期提醒",
          "text": "### 📆 周五排期提醒\n\n 您好!请查看本周的需求排期与任务管理情况,并确保所有任务按时完成。\n\n🔗 [需求排期与任务管理](https://alidocs.dingtalk.com/i/nodes/m9b)\n\n今日重点:\n- 检查本周任务完成情况\n- 准备下周工作计划\n\n注意事项:\n- 确保所有任务都有明确的截止日期和责任人\n- 及时更新任务进度,确保信息同步\n\n祝大家周末愉快,我们下周再见!\n\n @xx "
        },
        "at": {
          "atMobiles": [
            "xx"
          ]
        }
      }
    }
  ]
}

在这个JSON文件中,cron字段用于定义Cron表达式,这是一种用于指定定时任务执行时间的字符串表达式。Cron表达式由6个字段组成,分别对应秒、分钟、小时、天数、月份和星期几。每个字段都可以使用特定的符号来指定时间范围或间隔。如果您对Cron表达式不太熟悉,可以通过百度等搜索引擎进行了解。

为了操作这个JSON文件,使用了lowdb这个工具库。lowdb是一个轻量级的本地JSON数据库,它提供了简单的API来读写JSON文件。

const path = require('path');
const url = path.join(__dirname, './database/db.json')
module.exports = {
    createLowDb: async () => {
        const { JSONFilePreset } = await import('lowdb/node')
        const defaultData = { robot: [] }
        const db = await JSONFilePreset(url, defaultData)
        return db
    }
}

当时的想法是,用户通过我的页面填写机器人的配置信息后,这些数据将直接存储在JSON文件中。在项目初期,追求简单高效,没有必要引入复杂的数据库系统来处理这些数据。

注册周期性任务

为了实现周期性任务的注册,我们使用了node-schedule库。这个库允许我们在Node.js环境中轻松地设置和执行定时任务。通过Cron表达式,我们可以指定任务的执行时间,包括秒、分钟、小时、天、月和星期几。

首先,我们定义一个函数来注册周期性任务:

const schedule = require('node-schedule');
//省略

//注册一个周期性任务
const createJob = (dingRobot) => {
    return schedule.scheduleJob(dingRobot.cron, async () => {
        const url = getUrl({ access_token: dingRobot.access_token, secret: dingRobot.secret })
        const params = {
            msgtype: dingRobot.msgtype,
            [dingRobot.msgtype]: dingRobot[dingRobot.msgtype],
            at: dingRobot.at
        }
        const [err, result] = await to(axios.post(url, params))
        if (err) {
            console.err(err);
        }
    });
}

接下来,我们从JSON文件中读取所有周期性任务的配置,并循环注册这些任务:

const { createLowDb } = require('./db')
const schedule = require('node-schedule');

//注册多个周期性任务
const scheduleJob = async () => {
    const db = await createLowDb()
    const { robot = [] } = db.data
    const scheduleJobs = robot.map(dingRobot => {
        const job = createJob(dingRobot)
        return {
            job,
            access_token: dingRobot.access_token,
            secret: dingRobot.secret
        }
    })
    return scheduleJobs
}

module.exports = {
    createJob,
    scheduleJob
}

最后,在程序启动时,执行初始化操作:

const express = require('express')
const app = express()
const port = 3000
let jobs = []

//省略

app.listen(port, async () => {
    jobs = await scheduleJob()
    console.log(`Example app listening on port ${port}`)
})

增删改周期性任务

通过创建一个简单的Web服务器,并添加相应的路由接口来实现增删改JSON文件内容的功能。以下是使用Node.js和Express框架来实现这个功能代码:

const { createLowDb } = require('./db')
//省略
const updateDb = async (type, data, index) => {
    const db = await createLowDb()
    if (eq(type, 'add')) {
        await db.update(({ robot }) => robot.push(data))
        return
    }
    if (eq(type, 'delete')) {
        await db.update(({ robot }) => {
            robot.splice(index, 1)
            return robot
        })
        return
    }

    if (eq(type, 'set')) {
        await db.update(({ robot }) => {
            console.log(robot);
            robot[index] = data
            console.log(robot);
            return robot
        })
        return
    }
}

// 添加机器人
router.post('/robot', (req, res) => {
    const { access_token = "", secret = "", msgtype = "text", cron = "", content = {} } = req.body

    if (isEmpty(access_token) || isEmpty(secret) || isEmpty(msgtype) || isEmpty(cron) || isEmpty(content)) {
        res.send({ code: 500, message: '参数错误' })
        return
    }

    const findJob = jobs.find(job => {
        return eq(job.access_token, access_token) && eq(job.secret, secret)
    })
    const params = {
        access_token,
        secret,
        msgtype,
        cron,
        content
    }
    // 更改已存在的机器消息
    if (findJob) {
        findJob.job.cancel()
        const index = jobs.indexOf(findJob)
        jobs[index] = {
            job: createJob(params),
            access_token: params.access_token,
            secret: params.secret
        }
        updateDb('set', params, index)
    } else {
        jobs.push(createJob(params))
        updateDb('add', params)
    }
    res.send({ code: 200, message: 'success' })
})

这样后端部分功能完成。

前端页面和后端服务共用一个端口,并且前端页面放置在pages目录下,这样,后端和前端就可以通过同一个端口对外提供服务,实现逻辑:

const express = require('express')
//省略
app.get("/", (req, res) => {
    res.redirect('/index.html')
});
app.use(express.static(path.join(__dirname, './pages')))

部署应用

使用Docker部署一个Node应用,时区定为中国上海:

# 使用官方Node.jsDocker镜像作为基础镜像
FROM node:16-slim

# 设置工作目录为/app
WORKDIR /app

# 将package.json和package-lock.json(如果使用npm)复制到工作目录
COPY package*.json ./

# 安装项目依赖
RUN npm install

# 将项目中的所有文件复制到工作目录中
COPY . .

# 暴露容器的3000端口
EXPOSE 3000

# 定义环境变量
ENV NODE_ENV production

# 设置时区为上海
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && echo "Asia/Shanghai" > /etc/timezone

# 容器启动时执行的命令
CMD ["node", "index.js"]

在完成打包之后,把镜像推送到了阿里云容器镜像服务平台。接下来,在Linux服务器上执行拉取(pull)、运行(run)等操作,以部署应用。

image.png

完整代码链接:github.com/CatsAndMice…

最后

钉钉官方已经提供了解决方案来满足类似的需求,这是我之前没有预料到的,真是个疏忽。不过,偶尔编写一些工具类的代码,探索技术的可能性,确实是一件有趣的事情。