mcp-dingdingbot-server
by HundunOnline
// Package main provides functionality for interacting with DingDing Bot API
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"time"
)
// DingDing API endpoints
const (
// DINGDING_BOT_BASE_URL is the base URL for DingDing Bot API
DINGDING_BOT_BASE_URL = "https://oapi.dingtalk.com/robot"
// DINGDING_BOT_SEND_URL is the endpoint for sending messages
DINGDING_BOT_SEND_URL = DINGDING_BOT_BASE_URL + "/send?access_token="
// DINGDING_BOT_UPLOAD_URL is the endpoint for uploading media files
DINGDING_BOT_UPLOAD_URL = DINGDING_BOT_BASE_URL + "/upload_media?access_token="
)
// DingDingBot represents a DingDing Bot instance with configuration for API access
type DingDingBot struct {
// WebhookURL is the base URL for the DingDing Bot API
WebhookURL string
// WebhookKey is the access token for the DingDing Bot
WebhookKey string
// SignKey is the secret key used for signature verification
// This is optional but recommended for enhanced security
SignKey string
}
// NewDingDingBot creates a new DingDingBot instance with the provided configuration
// Parameters:
// - webhookURL: The base URL for the DingDing Bot API
// - webhookKey: The access token for the DingDing Bot
// - signKey: The secret key for signature verification (optional)
// Returns:
// - A pointer to a new DingDingBot instance
func NewDingDingBot(webhookURL, webhookKey, signKey string) *DingDingBot {
return &DingDingBot{
WebhookURL: webhookURL,
WebhookKey: webhookKey,
SignKey: signKey,
}
}
// generateSignature creates a signature for DingDing API requests using HMAC-SHA256
// The signature is used to verify that requests are coming from authorized sources
// Parameters:
// - timestamp: The current timestamp in milliseconds
// Returns:
// - The Base64-encoded HMAC-SHA256 signature
// - An error if signature generation fails
// Note:
// - Returns an empty string and nil error if SignKey is not provided
func (bot *DingDingBot) generateSignature(timestamp int64) (string, error) {
// If no sign key is provided, return empty signature
if bot.SignKey == "" {
return "", nil
}
// Format the string to sign: timestamp + newline + secret
stringToSign := fmt.Sprintf("%d\n%s", timestamp, bot.SignKey)
// Create HMAC-SHA256 signature
h := hmac.New(sha256.New, []byte(bot.SignKey))
if _, err := h.Write([]byte(stringToSign)); err != nil {
return "", fmt.Errorf("failed to create signature: %v", err)
}
// Encode the signature as Base64
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature, nil
}
// SendText sends a text message to the DingDing group.
// Parameters:
// - content: The text content of the message
// - atMobiles: Array of mobile numbers to @mention
// - atUserIds: Array of user IDs to @mention
// - isAtAll: Whether to @mention all members in the group
// Returns:
// - An error if the request fails, nil otherwise
func (bot *DingDingBot) SendText(content string, atMobiles []string, atUserIds []string, isAtAll bool) error {
if content == "" {
return fmt.Errorf("content cannot be empty")
}
payload := map[string]interface{}{
"msgtype": "text",
"text": map[string]interface{}{
"content": content,
},
"at": map[string]interface{}{
"atMobiles": atMobiles,
"atUserIds": atUserIds,
"isAtAll": isAtAll,
},
}
return bot.sendRequest(payload)
}
// SendMarkdown sends a markdown message to the DingDing group.
// Parameters:
// - title: The title of the markdown message
// - content: The markdown content of the message
// - atMobiles: Array of mobile numbers to @mention
// - atUserIds: Array of user IDs to @mention
// - isAtAll: Whether to @mention all members in the group
// Returns:
// - An error if the request fails, nil otherwise
func (bot *DingDingBot) SendMarkdown(title string, content string, atMobiles []string, atUserIds []string, isAtAll bool) error {
if title == "" {
return fmt.Errorf("title cannot be empty")
}
if content == "" {
return fmt.Errorf("content cannot be empty")
}
payload := map[string]interface{}{
"msgtype": "markdown",
"markdown": map[string]interface{}{
"title": title,
"text": content,
},
"at": map[string]interface{}{
"atMobiles": atMobiles,
"atUserIds": atUserIds,
"isAtAll": isAtAll,
},
}
return bot.sendRequest(payload)
}
// SendImage sends an image message to the DingDing group.
// Parameters:
// - base64Data: The Base64-encoded image data
// - md5: The MD5 hash of the image
// Returns:
// - An error if the request fails, nil otherwise
func (bot *DingDingBot) SendImage(base64Data string, md5 string) error {
if base64Data == "" {
return fmt.Errorf("base64Data cannot be empty")
}
if md5 == "" {
return fmt.Errorf("md5 cannot be empty")
}
payload := map[string]interface{}{
"msgtype": "image",
"image": map[string]interface{}{
"base64": base64Data,
"md5": md5,
},
}
return bot.sendRequest(payload)
}
// SendNews sends a link message to the DingDing group.
// Parameters:
// - title: The title of the news message
// - text: The text description of the news
// - messageUrl: The URL to open when clicking on the news
// - picUrl: The URL of the image to display in the news
// Returns:
// - An error if the request fails, nil otherwise
func (bot *DingDingBot) SendNews(title string, text string, messageUrl string, picUrl string) error {
if title == "" {
return fmt.Errorf("title cannot be empty")
}
if messageUrl == "" {
return fmt.Errorf("messageUrl cannot be empty")
}
payload := map[string]interface{}{
"msgtype": "link",
"link": map[string]interface{}{
"title": title,
"text": text,
"messageUrl": messageUrl,
"picUrl": picUrl,
},
}
return bot.sendRequest(payload)
}
// NewsArticle represents a news article in a news message.
// This struct is used to define the structure of a news article
// when sending news messages to DingDing.
type NewsArticle struct {
// Title is the title of the news article
Title string `json:"title"`
// Description is the text description of the news article
Description string `json:"description"`
// URL is the link that will be opened when clicking on the news article
URL string `json:"url"`
// PicURL is the URL of the image to display in the news article
PicURL string `json:"picurl"`
}
// SendTemplateCard sends an action card message to the DingDing group.
// Parameters:
// - title: The title of the template card
// - text: The text content of the template card
// - singleTitle: The title of the single button
// - singleURL: The URL to open when clicking the button
// - btnOrientation: The orientation of buttons ("0" for vertical, "1" for horizontal)
// Returns:
// - An error if the request fails, nil otherwise
func (bot *DingDingBot) SendTemplateCard(title string, text string, singleTitle string, singleURL string, btnOrientation string) error {
if title == "" {
return fmt.Errorf("title cannot be empty")
}
if text == "" {
return fmt.Errorf("text cannot be empty")
}
if singleTitle == "" {
return fmt.Errorf("singleTitle cannot be empty")
}
if singleURL == "" {
return fmt.Errorf("singleURL cannot be empty")
}
payload := map[string]interface{}{
"msgtype": "actionCard",
"actionCard": map[string]interface{}{
"title": title,
"text": text,
"singleTitle": singleTitle,
"singleURL": singleURL,
"btnOrientation": btnOrientation,
},
}
return bot.sendRequest(payload)
}
// UploadFile uploads a file to DingDing and returns the media ID.
// Parameters:
// - filePath: The path to the file to upload
// Returns:
// - The media ID of the uploaded file, which can be used in other API calls
// - An error if the upload fails, nil otherwise
func (bot *DingDingBot) UploadFile(filePath string) (string, error) {
if filePath == "" {
return "", fmt.Errorf("filePath cannot be empty")
}
// Check if we're in test mode (webhook key starts with "test-")
if len(bot.WebhookKey) >= 5 && bot.WebhookKey[:5] == "test-" {
fmt.Printf("TEST MODE: Would upload file %s to DingDing API\n", filePath)
return "test-media-id-12345", nil
}
// Open the file for reading
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
// Create a multipart form body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add the file to the form
part, err := writer.CreateFormFile("media", filePath)
if err != nil {
return "", fmt.Errorf("failed to create form file: %v", err)
}
// Copy the file content to the form
_, err = io.Copy(part, file)
if err != nil {
return "", fmt.Errorf("failed to copy file content: %v", err)
}
// Close the multipart writer
err = writer.Close()
if err != nil {
return "", fmt.Errorf("failed to close multipart writer: %v", err)
}
// Construct the request URL
requestURL := fmt.Sprintf("%s%s&type=file", bot.WebhookURL, bot.WebhookKey)
// Add signature if sign key is provided
if bot.SignKey != "" {
timestamp := time.Now().UnixNano() / 1e6
signature, err := bot.generateSignature(timestamp)
if err != nil {
return "", fmt.Errorf("failed to generate signature: %v", err)
}
requestURL = fmt.Sprintf("%s×tamp=%d&sign=%s", requestURL, timestamp, url.QueryEscape(signature))
}
// Send the HTTP POST request
resp, err := http.Post(requestURL, writer.FormDataContentType(), body)
if err != nil {
return "", fmt.Errorf("failed to send HTTP request: %v", err)
}
defer resp.Body.Close()
// Parse the response
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return "", fmt.Errorf("failed to decode response: %v", err)
}
// Check for API errors
if errcode, ok := result["errcode"].(float64); ok && errcode != 0 {
errmsg, _ := result["errmsg"].(string)
return "", fmt.Errorf("DingDing API error: %s", errmsg)
}
// Extract and return the media ID
mediaID, ok := result["media_id"].(string)
if !ok {
return "", fmt.Errorf("media_id not found in response")
}
return mediaID, nil
}
// sendRequest sends a request to the DingDing API with the given payload.
// This is an internal helper method used by the public message sending methods.
// Parameters:
// - payload: A map containing the message payload to send to the DingDing API
// Returns:
// - An error if the request fails, nil otherwise
func (bot *DingDingBot) sendRequest(payload map[string]interface{}) error {
// Convert the payload to JSON
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal JSON payload: %v", err)
}
// Check if we're in test mode (webhook key starts with "test-")
if len(bot.WebhookKey) >= 5 && bot.WebhookKey[:5] == "test-" {
fmt.Printf("TEST MODE: Would send to DingDing API: %s\n", string(jsonPayload))
return nil
}
// Construct the request URL
requestURL := bot.WebhookURL + bot.WebhookKey
// Add signature if sign key is provided
if bot.SignKey != "" {
timestamp := time.Now().UnixNano() / 1e6
signature, err := bot.generateSignature(timestamp)
if err != nil {
return fmt.Errorf("failed to generate signature: %v", err)
}
requestURL = fmt.Sprintf("%s×tamp=%d&sign=%s", requestURL, timestamp, url.QueryEscape(signature))
}
// Send the HTTP POST request
resp, err := http.Post(requestURL, "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
return fmt.Errorf("failed to send HTTP request: %v", err)
}
defer resp.Body.Close()
// Check the HTTP status code
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode)
}
// Parse the response
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return fmt.Errorf("failed to decode response: %v", err)
}
// Check for API errors
if errcode, ok := result["errcode"].(float64); ok && errcode != 0 {
errmsg, _ := result["errmsg"].(string)
return fmt.Errorf("DingDing API error: %s", errmsg)
}
return nil
}