355 lines
11 KiB
Go
355 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
"hyapp/pkg/configx"
|
|
"hyapp/pkg/tencentim"
|
|
)
|
|
|
|
const (
|
|
sendC2CMsgCommand = "v4/openim/sendmsg"
|
|
confirmSendValue = "SEND_REAL_TENCENT_IM_C2C"
|
|
)
|
|
|
|
type serviceConfig struct {
|
|
MySQLDSN string `yaml:"mysql_dsn"`
|
|
TencentIM tencentIMConfig `yaml:"tencent_im"`
|
|
}
|
|
|
|
type tencentIMConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
SDKAppID int64 `yaml:"sdk_app_id"`
|
|
SecretKey string `yaml:"secret_key"`
|
|
AdminIdentifier string `yaml:"admin_identifier"`
|
|
Endpoint string `yaml:"endpoint"`
|
|
}
|
|
|
|
type sendTextC2CRequest struct {
|
|
SyncOtherMachine int `json:"SyncOtherMachine"`
|
|
FromAccount string `json:"From_Account,omitempty"`
|
|
ToAccount string `json:"To_Account"`
|
|
MsgRandom uint32 `json:"MsgRandom"`
|
|
MsgBody []messageElement `json:"MsgBody"`
|
|
}
|
|
|
|
type messageElement struct {
|
|
MsgType string `json:"MsgType"`
|
|
MsgContent textMsgContent `json:"MsgContent"`
|
|
}
|
|
|
|
type textMsgContent struct {
|
|
Text string `json:"Text"`
|
|
}
|
|
|
|
type restResponse struct {
|
|
ActionStatus string `json:"ActionStatus"`
|
|
ErrorInfo string `json:"ErrorInfo"`
|
|
ErrorCode int `json:"ErrorCode"`
|
|
MsgKey string `json:"MsgKey,omitempty"`
|
|
}
|
|
|
|
type resultLine struct {
|
|
AtMS int64 `json:"at_ms"`
|
|
Mode string `json:"mode"`
|
|
ToAccount string `json:"to_account"`
|
|
Status string `json:"status"`
|
|
ErrorCode int `json:"error_code,omitempty"`
|
|
ErrorInfo string `json:"error_info,omitempty"`
|
|
MsgKey string `json:"msg_key,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
if err := run(context.Background(), os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, args []string) error {
|
|
var userConfigPath string
|
|
var imConfigPath string
|
|
var fromAccount string
|
|
var appCode string
|
|
var message string
|
|
var outputPath string
|
|
var limit int
|
|
var sleepMS int
|
|
var timeoutSeconds int
|
|
var send bool
|
|
var confirmSend string
|
|
var includeSender bool
|
|
|
|
flags := flag.NewFlagSet("im-c2c-broadcast", flag.ContinueOnError)
|
|
flags.StringVar(&userConfigPath, "user-config", "services/user-service/configs/config.yaml", "user-service YAML config used for mysql_dsn")
|
|
flags.StringVar(&imConfigPath, "im-config", "services/notice-service/configs/config.yaml", "service YAML config used for tencent_im")
|
|
flags.StringVar(&fromAccount, "from-account", "123456", "Tencent IM From_Account")
|
|
flags.StringVar(&appCode, "app-code", "lalu", "users.app_code filter")
|
|
flags.StringVar(&message, "message", "", "plain C2C text message body")
|
|
flags.StringVar(&outputPath, "output", "", "JSONL audit output path")
|
|
flags.IntVar(&limit, "limit", 0, "maximum active users to process; 0 means all")
|
|
flags.IntVar(&sleepMS, "sleep-ms", 25, "sleep between real sends")
|
|
flags.IntVar(&timeoutSeconds, "timeout-seconds", 10, "Tencent REST request timeout")
|
|
flags.BoolVar(&send, "send", false, "perform real Tencent IM sends; omitted means dry-run")
|
|
flags.StringVar(&confirmSend, "confirm-send", "", "must equal "+confirmSendValue+" when --send is set")
|
|
flags.BoolVar(&includeSender, "include-sender", false, "include from-account when it is also an active numeric user")
|
|
if err := flags.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
fromAccount = strings.TrimSpace(fromAccount)
|
|
appCode = strings.TrimSpace(appCode)
|
|
message = strings.TrimSpace(message)
|
|
if fromAccount == "" || appCode == "" || message == "" {
|
|
return errors.New("from-account, app-code and message are required")
|
|
}
|
|
if send && confirmSend != confirmSendValue {
|
|
return fmt.Errorf("--send requires --confirm-send=%s", confirmSendValue)
|
|
}
|
|
if sleepMS < 0 || timeoutSeconds <= 0 || limit < 0 {
|
|
return errors.New("sleep-ms, timeout-seconds and limit are invalid")
|
|
}
|
|
|
|
userCfg, err := loadConfig(userConfigPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load user config: %w", err)
|
|
}
|
|
imCfg, err := loadConfig(imConfigPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load im config: %w", err)
|
|
}
|
|
if strings.TrimSpace(userCfg.MySQLDSN) == "" {
|
|
return errors.New("user config mysql_dsn is empty")
|
|
}
|
|
if err := validateTencentIMConfig(imCfg.TencentIM); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := sql.Open("mysql", userCfg.MySQLDSN)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
return fmt.Errorf("ping mysql: %w", err)
|
|
}
|
|
|
|
targets, totalActive, err := listActiveUserIDs(ctx, db, appCode, fromAccount, includeSender, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mode := "dry-run"
|
|
if send {
|
|
mode = "send"
|
|
}
|
|
if outputPath == "" {
|
|
outputPath = filepath.Join("run", fmt.Sprintf("im-c2c-broadcast-%s.jsonl", time.Now().UTC().Format("20060102-150405")))
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
output, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer output.Close()
|
|
|
|
fmt.Printf("mode=%s app_code=%s sdk_app_id=%d endpoint=%s from_account=%s total_active=%d targets=%d output=%s\n",
|
|
mode, appCode, imCfg.TencentIM.SDKAppID, firstNonEmpty(imCfg.TencentIM.Endpoint, tencentim.DefaultEndpoint), fromAccount, totalActive, len(targets), outputPath)
|
|
if len(targets) > 0 {
|
|
fmt.Printf("first_target=%s last_target=%s\n", targets[0], targets[len(targets)-1])
|
|
}
|
|
if !send {
|
|
for _, target := range targets {
|
|
if err := writeResult(output, resultLine{AtMS: time.Now().UTC().UnixMilli(), Mode: mode, ToAccount: target, Status: "dry_run"}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Println("dry_run_complete=true")
|
|
return nil
|
|
}
|
|
|
|
client := &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}
|
|
for i, target := range targets {
|
|
response, err := sendTencentText(ctx, client, imCfg.TencentIM, fromAccount, target, message)
|
|
line := resultLine{AtMS: time.Now().UTC().UnixMilli(), Mode: mode, ToAccount: target, Status: "ok"}
|
|
if err != nil {
|
|
line.Status = "failed"
|
|
line.ErrorInfo = err.Error()
|
|
} else if response.ErrorCode != 0 || !strings.EqualFold(firstNonEmpty(response.ActionStatus, "OK"), "OK") {
|
|
line.Status = "failed"
|
|
line.ErrorCode = response.ErrorCode
|
|
line.ErrorInfo = response.ErrorInfo
|
|
} else {
|
|
line.MsgKey = response.MsgKey
|
|
}
|
|
if err := writeResult(output, line); err != nil {
|
|
return err
|
|
}
|
|
if line.Status != "ok" {
|
|
fmt.Printf("target=%s status=failed error_code=%d error=%s\n", target, line.ErrorCode, line.ErrorInfo)
|
|
}
|
|
if sleepMS > 0 && i+1 < len(targets) {
|
|
time.Sleep(time.Duration(sleepMS) * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
fmt.Println("send_complete=true")
|
|
return nil
|
|
}
|
|
|
|
func loadConfig(path string) (serviceConfig, error) {
|
|
var cfg serviceConfig
|
|
err := configx.LoadYAML(strings.TrimSpace(path), &cfg)
|
|
return cfg, err
|
|
}
|
|
|
|
func validateTencentIMConfig(cfg tencentIMConfig) error {
|
|
if cfg.SDKAppID <= 0 || strings.TrimSpace(cfg.SecretKey) == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
|
|
return errors.New("tencent_im sdk_app_id, secret_key and admin_identifier are required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func listActiveUserIDs(ctx context.Context, db *sql.DB, appCode string, fromAccount string, includeSender bool, limit int) ([]string, int64, error) {
|
|
var totalActive int64
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE app_code = ? AND status = 'active'`, appCode).Scan(&totalActive); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
excludeUserID := int64(0)
|
|
if !includeSender {
|
|
if value, err := strconv.ParseInt(fromAccount, 10, 64); err == nil && value > 0 {
|
|
excludeUserID = value
|
|
}
|
|
}
|
|
query := `SELECT user_id FROM users WHERE app_code = ? AND status = 'active'`
|
|
params := []any{appCode}
|
|
if excludeUserID > 0 {
|
|
query += ` AND user_id <> ?`
|
|
params = append(params, excludeUserID)
|
|
}
|
|
query += ` ORDER BY user_id ASC`
|
|
if limit > 0 {
|
|
query += ` LIMIT ?`
|
|
params = append(params, limit)
|
|
}
|
|
rows, err := db.QueryContext(ctx, query, params...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
targets := make([]string, 0)
|
|
for rows.Next() {
|
|
var userID int64
|
|
if err := rows.Scan(&userID); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
targets = append(targets, strconv.FormatInt(userID, 10))
|
|
}
|
|
return targets, totalActive, rows.Err()
|
|
}
|
|
|
|
func sendTencentText(ctx context.Context, client *http.Client, cfg tencentIMConfig, fromAccount string, toAccount string, text string) (restResponse, error) {
|
|
requestBody := sendTextC2CRequest{
|
|
SyncOtherMachine: 2,
|
|
FromAccount: fromAccount,
|
|
ToAccount: toAccount,
|
|
MsgRandom: randomUint32(),
|
|
MsgBody: []messageElement{
|
|
{MsgType: "TIMTextElem", MsgContent: textMsgContent{Text: text}},
|
|
},
|
|
}
|
|
body, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return restResponse{}, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(cfg, sendC2CMsgCommand), bytes.NewReader(body))
|
|
if err != nil {
|
|
return restResponse{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return restResponse{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return restResponse{}, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return restResponse{}, fmt.Errorf("tencent im http status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
var parsed restResponse
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return restResponse{}, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func endpoint(cfg tencentIMConfig, command string) string {
|
|
adminSig, _ := tencentim.GenerateUserSig(tencentim.UserSigConfig{
|
|
SDKAppID: cfg.SDKAppID,
|
|
SecretKey: cfg.SecretKey,
|
|
TTL: 24 * time.Hour,
|
|
}, strings.TrimSpace(cfg.AdminIdentifier), time.Now().UTC())
|
|
|
|
values := url.Values{}
|
|
values.Set("sdkappid", strconv.FormatInt(cfg.SDKAppID, 10))
|
|
values.Set("identifier", strings.TrimSpace(cfg.AdminIdentifier))
|
|
values.Set("usersig", adminSig.UserSig)
|
|
values.Set("random", strconv.FormatUint(uint64(randomUint32()), 10))
|
|
values.Set("contenttype", "json")
|
|
|
|
return "https://" + strings.TrimSuffix(firstNonEmpty(cfg.Endpoint, tencentim.DefaultEndpoint), "/") + "/" + strings.TrimPrefix(command, "/") + "?" + values.Encode()
|
|
}
|
|
|
|
func writeResult(output *os.File, line resultLine) error {
|
|
body, err := json.Marshal(line)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := output.Write(append(body, '\n')); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomUint32() uint32 {
|
|
var payload [4]byte
|
|
if _, err := rand.Read(payload[:]); err != nil {
|
|
return uint32(time.Now().UnixNano())
|
|
}
|
|
return binary.BigEndian.Uint32(payload[:])
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|