chore: 1. callbackService 结构优化 2. dingtalkClient 目录结构优化 3. tools_bot 结构优化

This commit is contained in:
fuzhongyun 2025-11-17 10:16:35 +08:00
parent eaa2d4ca7e
commit 8e74c434ae
7 changed files with 263 additions and 225 deletions

View File

@ -1,4 +1,4 @@
package dingtalk_contact
package dingtalk
import (
"ai_scheduler/internal/config"
@ -10,12 +10,12 @@ import (
"github.com/alibabacloud-go/tea/tea"
)
type Client struct {
type ContactClient struct {
config *config.Config
cli *contact.Client
}
func NewContactClient(config *config.Config) (*Client, error) {
func NewContactClient(config *config.Config) (*ContactClient, error) {
cfg := &openapi.Config{
AccessKeyId: tea.String(config.Tools.DingTalkBot.APIKey),
AccessKeySecret: tea.String(config.Tools.DingTalkBot.APISecret),
@ -26,7 +26,7 @@ func NewContactClient(config *config.Config) (*Client, error) {
if err != nil {
return nil, err
}
return &Client{config: config, cli: c}, nil
return &ContactClient{config: config, cli: c}, nil
}
type SearchUserReq struct {
@ -40,7 +40,7 @@ type SearchUserResp struct {
Body interface{}
}
func (c *Client) SearchUserOne(accessToken string, name string) (string, error) {
func (c *ContactClient) SearchUserOne(accessToken string, name string) (string, error) {
headers := &contact.SearchUserHeaders{}
headers.XAcsDingtalkAccessToken = tea.String(accessToken)
resp, err := c.cli.SearchUserWithOptions(&contact.SearchUserRequest{

View File

@ -1,4 +1,4 @@
package dingtalk_notable
package dingtalk
import (
"ai_scheduler/internal/config"
@ -10,12 +10,12 @@ import (
"github.com/alibabacloud-go/tea/tea"
)
type Client struct {
type NotableClient struct {
config *config.Config
cli *notable.Client
}
func NewNotableClient(config *config.Config) (*Client, error) {
func NewNotableClient(config *config.Config) (*NotableClient, error) {
cfg := &openapi.Config{
AccessKeyId: tea.String(config.Tools.DingTalkBot.APIKey),
AccessKeySecret: tea.String(config.Tools.DingTalkBot.APISecret),
@ -26,7 +26,7 @@ func NewNotableClient(config *config.Config) (*Client, error) {
if err != nil {
return nil, err
}
return &Client{config: config, cli: c}, nil
return &NotableClient{config: config, cli: c}, nil
}
type UpdateRecordReq struct {
@ -41,7 +41,7 @@ type UpdateRecordsserResp struct {
Body interface{}
}
func (c *Client) UpdateRecord(accessToken string, req *UpdateRecordReq) (bool, error) {
func (c *NotableClient) UpdateRecord(accessToken string, req *UpdateRecordReq) (bool, error) {
headers := &notable.UpdateRecordsHeaders{}
headers.XAcsDingtalkAccessToken = tea.String(accessToken)
resp, err := c.cli.UpdateRecordsWithOptions(

View File

@ -1,5 +1,7 @@
package dingtalk
// 旧版sdk客户端 - 在用,勿删!
import (
"ai_scheduler/internal/config"
"bytes"
@ -15,26 +17,26 @@ import (
"github.com/fastwego/dingding"
)
type Client struct {
cfg *config.Config
sdkClient *dingding.Client
type OldClient struct {
config *config.Config
cli *dingding.Client
atm *dingding.DefaultAccessTokenManager
}
func NewDingTalkClient(cfg *config.Config) *Client {
func NewOldClient(config *config.Config) *OldClient {
atm := &dingding.DefaultAccessTokenManager{
Id: cfg.Tools.DingTalkBot.APIKey,
Id: config.Tools.DingTalkBot.APIKey,
Name: "access_token",
GetRefreshRequestFunc: func() *http.Request {
params := url.Values{}
params.Add("appkey", cfg.Tools.DingTalkBot.APIKey)
params.Add("appsecret", cfg.Tools.DingTalkBot.APISecret)
params.Add("appkey", config.Tools.DingTalkBot.APIKey)
params.Add("appsecret", config.Tools.DingTalkBot.APISecret)
req, _ := http.NewRequest(http.MethodGet, dingding.ServerUrl+"/gettoken?"+params.Encode(), nil)
return req
},
Cache: file.New(os.TempDir()),
}
return &Client{cfg: cfg, sdkClient: dingding.NewClient(atm), atm: atm}
return &OldClient{config: config, cli: dingding.NewClient(atm), atm: atm}
}
type UserDetail struct {
@ -43,7 +45,7 @@ type UserDetail struct {
UnionID string `json:"unionid"`
}
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
func (c *OldClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
@ -55,10 +57,10 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.sdkClient.Do(req)
return c.cli.Do(req)
}
func (c *Client) QueryUserDetails(ctx context.Context, userId string) (*UserDetail, error) {
func (c *OldClient) QueryUserDetails(ctx context.Context, userId string) (*UserDetail, error) {
body := struct {
UserId string `json:"userid"`
Language string `json:"language,omitempty"`
@ -82,7 +84,7 @@ func (c *Client) QueryUserDetails(ctx context.Context, userId string) (*UserDeta
return &resp.Result, nil
}
func (c *Client) QueryUserDetailsByMobile(ctx context.Context, mobile string) (*UserDetail, error) {
func (c *OldClient) QueryUserDetailsByMobile(ctx context.Context, mobile string) (*UserDetail, error) {
body := struct {
Mobile string `json:"mobile"`
}{Mobile: mobile}
@ -104,8 +106,8 @@ func (c *Client) QueryUserDetailsByMobile(ctx context.Context, mobile string) (*
}
return &resp.Result, nil
}
// GetAccessToken 通过 fastwego 的 AccessTokenManager 获取当前可用 access_token
func (c *Client) GetAccessToken() (string, error) {
func (c *OldClient) GetAccessToken() (string, error) {
return c.atm.GetAccessToken()
}

View File

@ -2,8 +2,6 @@ package pkg
import (
"ai_scheduler/internal/pkg/dingtalk"
"ai_scheduler/internal/pkg/dingtalk_contact"
"ai_scheduler/internal/pkg/dingtalk_notable"
"ai_scheduler/internal/pkg/utils_langchain"
"ai_scheduler/internal/pkg/utils_ollama"
@ -16,7 +14,7 @@ var ProviderSetClient = wire.NewSet(
utils_langchain.NewUtilLangChain,
utils_ollama.NewClient,
NewSafeChannelPool,
dingtalk.NewDingTalkClient,
dingtalk_contact.NewContactClient,
dingtalk_notable.NewNotableClient,
dingtalk.NewOldClient,
dingtalk.NewContactClient,
dingtalk.NewNotableClient,
)

View File

@ -2,11 +2,12 @@ package services
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/data/constants"
errorcode "ai_scheduler/internal/data/error"
"ai_scheduler/internal/entitys"
"ai_scheduler/internal/gateway"
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/dingtalk"
"ai_scheduler/internal/pkg/dingtalk_contact"
"ai_scheduler/internal/pkg/dingtalk_notable"
"ai_scheduler/internal/pkg/util"
"ai_scheduler/internal/tools_bot"
"context"
@ -21,17 +22,17 @@ import (
type CallbackService struct {
cfg *config.Config
gateway *gateway.Gateway
dingtalkClient *dingtalk.Client
dingtalkContactClient *dingtalk_contact.Client
dingtalkNotableClient *dingtalk_notable.Client
dingtalkOldClient *dingtalk.OldClient
dingtalkContactClient *dingtalk.ContactClient
dingtalkNotableClient *dingtalk.NotableClient
botTool *tools_bot.BotTool
}
func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkClient *dingtalk.Client, dingtalkContactClient *dingtalk_contact.Client, dingtalkNotableClient *dingtalk_notable.Client, botTool *tools_bot.BotTool) *CallbackService {
func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkOldClient *dingtalk.OldClient, dingtalkContactClient *dingtalk.ContactClient, dingtalkNotableClient *dingtalk.NotableClient, botTool *tools_bot.BotTool) *CallbackService {
return &CallbackService{
cfg: cfg,
gateway: gateway,
dingtalkClient: dingtalkClient,
dingtalkOldClient: dingtalkOldClient,
dingtalkContactClient: dingtalkContactClient,
dingtalkNotableClient: dingtalkNotableClient,
botTool: botTool,
@ -76,7 +77,7 @@ func (s *CallbackService) Callback(c *fiber.Ctx) error {
// 时间窗口(如果提供了 ts 则校验,否则跳过),窗口 5 分钟
if ts != "" && !validateTimestamp(ts, 5*time.Minute) {
// return errorcode.AuthNotFound
return errorcode.AuthNotFound
}
// 解析 Envelope
@ -135,25 +136,59 @@ func parseInt64(s string) (int64, bool) {
}
func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) error {
switch env.Action {
// bug/优化完成回调
case ActionBugOptimizationSubmitDone:
// 获取 session_id
// 校验taskId
sessionID, ok := s.botTool.GetSessionByTaskID(env.TaskID)
if !ok {
return errorcode.ParamErr("missing session_id for task_id: %s", env.TaskID)
}
ctx := c.Context()
switch env.Action {
// bug/优化完成回调
case ActionBugOptimizationSubmitDone:
// 业务处理
msg, businessErr := s.handleBugOptimizationSubmitDone(ctx, env.Data)
if businessErr != nil {
return businessErr
}
// 发送日志
s.sendStreamLog(sessionID, msg)
// 删除映射
s.botTool.DelTaskMapping(env.TaskID)
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
case ActionBugOptimizationSubmitUpdate:
// 业务处理
msg, businessErr := s.handleBugOptimizationSubmitUpdate(ctx, env.Data)
if businessErr != nil {
return businessErr
}
s.sendStreamLog(sessionID, msg)
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
default:
return errorcode.ParamErr("unknown action: %s", env.Action)
}
}
// handleBugOptimizationSubmitDone 处理 bug 优化提交完成回调
func (s *CallbackService) handleBugOptimizationSubmitDone(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) {
var data BugOptimizationSubmitDoneData
if err := json.Unmarshal(env.Data, &data); err != nil {
return errorcode.ParamErr("invalid data type: %v", err)
if err := json.Unmarshal(taskData, &data); err != nil {
return "", errorcode.ParamErr("invalid data type: %v", err)
}
if len(data.Receivers) == 0 {
return errorcode.ParamErr("empty receivers")
return "", errorcode.ParamErr("empty receivers")
}
// 构建接收者
receivers := s.getDingtalkReceivers(c.Context(), data.Receivers)
receivers := s.getDingtalkReceivers(ctx, data.Receivers)
if receivers == "" {
return "", errorcode.ParamErr("invalid receivers")
}
// 构建跳转链接
var detailPage string
@ -165,73 +200,14 @@ func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) err
msg = util.ReplacePlaceholder(msg, "receivers", receivers)
msg = util.ReplacePlaceholder(msg, "detail_page", util.EscapeJSONString(detailPage))
s.gateway.SendToUid(sessionID, []byte(msg))
// 删除映射
s.botTool.DelTaskMapping(env.TaskID)
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
case ActionBugOptimizationSubmitUpdate:
// 获取 session_id
// sessionID, ok := s.botTool.GetSessionByTaskID(env.TaskID)
// if !ok {
// return errorcode.ParamErr("missing session_id for task_id: %s", env.TaskID)
// }
var data BugOptimizationSubmitUpdateData
if err := json.Unmarshal(env.Data, &data); err != nil {
return errorcode.ParamErr("invalid data type: %v", err)
}
if data.Creator == "" {
return errorcode.ParamErr("empty creator")
}
// 获取创建者uid
accessToken, _ := s.dingtalkClient.GetAccessToken()
creatorId, err := s.dingtalkContactClient.SearchUserOne(accessToken, data.Creator)
if err != nil {
return errorcode.ParamErr("invalid data type: %v", err)
}
// 获取用户详情
userDetails, err := s.dingtalkClient.QueryUserDetails(c.Context(), creatorId)
if err != nil {
return errorcode.ParamErr("invalid data type: %v", err)
}
if userDetails == nil {
return errorcode.ParamErr("user details not found")
}
unionId := userDetails.UnionID
// 更新记录
ok, err := s.dingtalkNotableClient.UpdateRecord(accessToken, &dingtalk_notable.UpdateRecordReq{
BaseId: data.BaseId,
SheetId: data.SheetId,
RecordId: data.RecordId,
OperatorId: tools_bot.BotBugOptimizationSubmitAdminUnionId,
CreatorUnionId: unionId,
})
if err != nil {
return errorcode.ParamErr("invalid data type: %v", err)
}
if !ok {
return errorcode.ParamErr("update record failed")
}
// s.gateway.SendToUid(sessionID, []byte("问题记录即将完成"))
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
default:
return errorcode.ParamErr("unknown action: %s", env.Action)
}
return msg, nil
}
// getDingtalkReceivers 解析接收者字符串为 DingTalk 用户 ID 列表
func (s *CallbackService) getDingtalkReceivers(ctx context.Context, receiverIds []string) string {
var receiverNames []string
for _, receiverId := range receiverIds {
userDetails, err := s.dingtalkClient.QueryUserDetails(ctx, receiverId)
userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, receiverId)
if err != nil {
return ""
}
@ -245,3 +221,60 @@ func (s *CallbackService) getDingtalkReceivers(ctx context.Context, receiverIds
return receivers
}
// sendStreamLog 发送流式日志
func (s *CallbackService) sendStreamLog(sessionID string, content string) {
streamLog := entitys.Response{
Index: constants.BotToolsBugOptimizationSubmit,
Content: content,
Type: entitys.ResponseLog,
}
streamLogBytes := pkg.JsonByteIgonErr(streamLog)
s.gateway.SendToUid(sessionID, streamLogBytes)
}
// handleBugOptimizationSubmitUpdate 处理 bug 优化提交更新回调
func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) {
var data BugOptimizationSubmitUpdateData
if err := json.Unmarshal(taskData, &data); err != nil {
return "", errorcode.ParamErr("invalid data type: %v", err)
}
if data.Creator == "" {
return "", errorcode.ParamErr("empty creator")
}
// 获取创建者uid
accessToken, _ := s.dingtalkOldClient.GetAccessToken()
creatorId, err := s.dingtalkContactClient.SearchUserOne(accessToken, data.Creator)
if err != nil {
return "", errorcode.ParamErr("invalid data type: %v", err)
}
// 获取用户详情
userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, creatorId)
if err != nil {
return "", errorcode.ParamErr("invalid data type: %v", err)
}
if userDetails == nil {
return "", errorcode.ParamErr("user details not found")
}
unionId := userDetails.UnionID
// 更新记录
ok, err := s.dingtalkNotableClient.UpdateRecord(accessToken, &dingtalk.UpdateRecordReq{
BaseId: data.BaseId,
SheetId: data.SheetId,
RecordId: data.RecordId,
OperatorId: tools_bot.BotBugOptimizationSubmitAdminUnionId,
CreatorUnionId: unionId,
})
if err != nil {
return "", errorcode.ParamErr("invalid data type: %v", err)
}
if !ok {
return "", errorcode.ParamErr("update record failed")
}
return "问题记录即将完成", nil
}

View File

@ -0,0 +1,115 @@
package tools_bot
import (
errors "ai_scheduler/internal/data/error"
"ai_scheduler/internal/entitys"
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/l_request"
"context"
"encoding/json"
"fmt"
"time"
"github.com/gofiber/fiber/v2/log"
"github.com/google/uuid"
"xorm.io/builder"
)
// BugOptimizationSubmitForm 工单提交表单参数
type BugOptimizationSubmitForm struct {
Mark string `json:"mark"` // 工单标识
Text string `json:"text"` // 工单描述
Img string `json:"img"` // 工单截图
Creator string `json:"creator"` // 工单创建人
TaskId string `json:"task_id"` // 当初任务ID
}
const (
// 工单QA
BotBugOptimizationSubmitQA = "温子新"
BotBugOptimizationSubmitPM = "贺泽琨"
// 管理员unionId - fzy
BotBugOptimizationSubmitAdminUnionId = "uoCiPKNdFmuiSFmAiiXmmiSKpQiEiE"
)
// BugOptimizationSubmit 工单提交
func (w *BotTool) BugOptimizationSubmit(ctx context.Context, requireData *entitys.RequireData) (err error) {
// 获取用户信息
cond := builder.NewCond()
cond = cond.And(builder.Eq{"session_id": requireData.Session})
sessionInfo, err := w.sessionImpl.GetOneBySearch(&cond)
if err != nil {
err = errors.SysErr("获取会话信息失败:%v", err.Error())
return
}
userName := sessionInfo["user_name"].(string)
// 构建工单表单参数
body := BugOptimizationSubmitForm{
Mark: requireData.Match.Index,
Text: requireData.Req.Text,
Img: requireData.Req.Img,
Creator: userName,
TaskId: uuid.New().String(),
}
request := l_request.Request{
Url: "https://connector.dingtalk.com/webhook/flow/10352c521dd02104cee9000c",
Method: "POST",
Headers: map[string]string{
"Content-Type": "application/json",
},
JsonByte: pkg.JsonByteIgonErr(body),
}
res, err := request.Send()
if err != nil {
log.Errorf("发送请求失败: %s", err.Error())
return
}
data := make(map[string]bool)
if err = json.Unmarshal(res.Content, &data); err != nil {
return fmt.Errorf("解析工单响应失败:%w", err)
}
if data["success"] {
// 记录 task_id 到 session_id 的映射
w.SetTaskMapping(body.TaskId, requireData.Session)
// 等待异步回调完成再结束
for {
sessionID, ok := w.GetSessionByTaskID(body.TaskId)
if !ok || sessionID != requireData.Session {
break
}
entitys.ResLoading(requireData.Ch, requireData.Match.Index, "问题记录中...")
time.Sleep(time.Second)
}
return
}
entitys.ResJson(requireData.Ch, requireData.Match.Index, fmt.Sprintf("bug问题请咨询 @%s ,优化建议请咨询 @%s 。", BotBugOptimizationSubmitQA, BotBugOptimizationSubmitPM))
return
}
// SetTaskMapping 设置 task_id 到 session_id 的映射(内存版)。
// 后续考虑使用 Redis确保幂等与过期清理。
func (w *BotTool) SetTaskMapping(taskID, sessionID string) {
if taskID == "" || sessionID == "" {
return
}
w.taskMap[taskID] = sessionID
}
// GetSessionByTaskID 读取映射
func (w *BotTool) GetSessionByTaskID(taskID string) (string, bool) {
v, ok := w.taskMap[taskID]
return v, ok
}
// DelTaskMapping 删除 task_id 到 session_id 的映射(内存版)。
func (w *BotTool) DelTaskMapping(taskID string) {
delete(w.taskMap, taskID)
}

View File

@ -6,17 +6,10 @@ import (
errors "ai_scheduler/internal/data/error"
"ai_scheduler/internal/data/impl"
"ai_scheduler/internal/entitys"
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/l_request"
"ai_scheduler/internal/pkg/utils_ollama"
"context"
"encoding/json"
"fmt"
"time"
"github.com/gofiber/fiber/v2/log"
"github.com/google/uuid"
"xorm.io/builder"
)
type BotTool struct {
@ -31,15 +24,6 @@ func NewBotTool(config *config.Config, llm *utils_ollama.Client, sessionImpl *im
return &BotTool{config: config, llm: llm, sessionImpl: sessionImpl, taskMap: make(map[string]string)}
}
// BugOptimizationSubmitForm 工单提交表单参数
type BugOptimizationSubmitForm struct {
Mark string `json:"mark"` // 工单标识
Text string `json:"text"` // 工单描述
Img string `json:"img"` // 工单截图
Creator string `json:"creator"` // 工单创建人
TaskId string `json:"task_id"` // 当初任务ID
}
// Execute 执行直连天下订单详情查询
func (w *BotTool) Execute(ctx context.Context, toolName string, requireData *entitys.RequireData) (err error) {
switch toolName {
@ -51,97 +35,3 @@ func (w *BotTool) Execute(ctx context.Context, toolName string, requireData *ent
}
return
}
const (
// 工单QA
BotBugOptimizationSubmitQA = "温子新"
BotBugOptimizationSubmitPM = "贺泽琨"
// 管理员unionId - fzy
BotBugOptimizationSubmitAdminUnionId = "uoCiPKNdFmuiSFmAiiXmmiSKpQiEiE"
)
// 现存问题:
// 1. 回调时 session 直接传入不安全 todo
// 2. 创建人无法指定[钉钉用户],影响后续状态变化时通知
// 3. 回调接口,[接收人]、[文档地址不能]动态配置
// 4. 测试环境与线上环境,使用的不是同一个钉钉主体
func (w *BotTool) BugOptimizationSubmit(ctx context.Context, requireData *entitys.RequireData) (err error) {
// 获取用户信息
cond := builder.NewCond()
cond = cond.And(builder.Eq{"session_id": requireData.Session})
sessionInfo, err := w.sessionImpl.GetOneBySearch(&cond)
if err != nil {
err = errors.SysErr("获取会话信息失败:%v", err.Error())
return
}
userName := sessionInfo["user_name"].(string)
// 构建工单表单参数
body := BugOptimizationSubmitForm{
Mark: requireData.Match.Index,
Text: requireData.Req.Text,
Img: requireData.Req.Img,
Creator: userName,
TaskId: uuid.New().String(),
}
request := l_request.Request{
Url: "https://connector.dingtalk.com/webhook/flow/10352c521dd02104cee9000c",
Method: "POST",
Headers: map[string]string{
"Content-Type": "application/json",
},
JsonByte: pkg.JsonByteIgonErr(body),
}
res, err := request.Send()
if err != nil {
log.Errorf("发送请求失败: %s", err.Error())
return
}
data := make(map[string]bool)
if err = json.Unmarshal(res.Content, &data); err != nil {
return fmt.Errorf("解析工单响应失败:%w", err)
}
if data["success"] {
// 记录 task_id 到 session_id 的映射
w.SetTaskMapping(body.TaskId, requireData.Session)
// 等待异步回调完成再结束
for {
sessionID, ok := w.GetSessionByTaskID(body.TaskId)
if !ok || sessionID != requireData.Session {
break
}
entitys.ResLoading(requireData.Ch, requireData.Match.Index, "问题内容记录中...")
time.Sleep(time.Second)
}
return
}
entitys.ResJson(requireData.Ch, requireData.Match.Index, fmt.Sprintf("bug问题请咨询 @%s ,优化建议请咨询 @%s 。", BotBugOptimizationSubmitQA, BotBugOptimizationSubmitPM))
return
}
// SetTaskMapping 设置 task_id 到 session_id 的映射(内存版)。
// 后续考虑使用 Redis确保幂等与过期清理。
func (w *BotTool) SetTaskMapping(taskID, sessionID string) {
if taskID == "" || sessionID == "" {
return
}
w.taskMap[taskID] = sessionID
}
// GetSessionByTaskID 读取映射
func (w *BotTool) GetSessionByTaskID(taskID string) (string, bool) {
v, ok := w.taskMap[taskID]
return v, ok
}
// DelTaskMapping 删除 task_id 到 session_id 的映射(内存版)。
func (w *BotTool) DelTaskMapping(taskID string) {
delete(w.taskMap, taskID)
}