Class: Clacky::Channel::Adapters::DingTalk::Adapter

Inherits:
Base
  • Object
show all
Defined in:
lib/clacky/server/channel/adapters/dingtalk/adapter.rb

Constant Summary collapse

WEBHOOK_SAFETY_MARGIN_MS =
5 * 60 * 1000

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#platform_id, #supports_message_updates?, #update_message

Constructor Details

#initialize(config) ⇒ Adapter

Returns a new instance of Adapter.



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 44

def initialize(config)
  @config        = config
  @api_client    = ApiClient.new(
    client_id:     config[:client_id],
    client_secret: config[:client_secret]
  )
  @stream_client = nil
  @running       = false
  # chat_id => { url:, expires_at_ms: } — sessionWebhook is per-message
  # and expires (~2h). We cache it from inbound events and validate on send.
  @webhook_urls  = {}
  @webhook_mutex = Mutex.new
  # chat_id => { robot_code:, conv_id:, user_id:, conv_type: } — needed
  # to route OAPI calls (e.g. send_file) which can't go through webhook.
  @routes        = {}
  @routes_mutex  = Mutex.new
end

Class Method Details

.env_keysObject



16
17
18
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 16

def self.env_keys
  %w[IM_DINGTALK_CLIENT_ID IM_DINGTALK_CLIENT_SECRET IM_DINGTALK_ALLOWED_USERS]
end

.platform_config(data) ⇒ Object



20
21
22
23
24
25
26
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 20

def self.platform_config(data)
  {
    client_id:     data["IM_DINGTALK_CLIENT_ID"],
    client_secret: data["IM_DINGTALK_CLIENT_SECRET"],
    allowed_users: data["IM_DINGTALK_ALLOWED_USERS"]&.split(",")&.map(&:strip)&.reject(&:empty?)
  }
end

.platform_idObject



12
13
14
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 12

def self.platform_id
  :dingtalk
end

.set_env_data(data, config) ⇒ Object



28
29
30
31
32
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 28

def self.set_env_data(data, config)
  data["IM_DINGTALK_CLIENT_ID"]     = config[:client_id]
  data["IM_DINGTALK_CLIENT_SECRET"]  = config[:client_secret]
  data["IM_DINGTALK_ALLOWED_USERS"]  = Array(config[:allowed_users]).join(",")
end

.test_connection(fields) ⇒ Object



34
35
36
37
38
39
40
41
42
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 34

def self.test_connection(fields)
  client = ApiClient.new(
    client_id:     fields[:client_id].to_s.strip,
    client_secret: fields[:client_secret].to_s.strip
  )
  client.test_connection
rescue => e
  { ok: false, error: e.message }
end

Instance Method Details

#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Object

Send a local file (image or generic file) as a native attachment. Webhook can’t deliver attachments — use OAPI sendMessage with mediaId.

Parameters:

  • chat_id (String)
  • path (String)

    local file path

  • name (String, nil) (defaults to: nil)

    display filename (not used by image msg)



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 98

def send_file(chat_id, path, name: nil, reply_to: nil)
  unless File.exist?(path)
    Clacky::Logger.warn("[dingtalk] send_file: file not found #{path}")
    return { ok: false, error: "file_not_found" }
  end

  route = resolve_route(chat_id)
  unless route
    Clacky::Logger.warn("[dingtalk] send_file: no routing info for chat #{chat_id}")
    return { ok: false, error: "no_route" }
  end

  kind = image_file?(path) ? :image : :file

  # Non-image files outside DingTalk's accepted extension list
  # (sampleFile rejects anything not in SUPPORTED_FILE_EXTS).
  # Surface the failure directly to the user in the chat,
  # disguised as a DingTalk system message so it's clear the
  # restriction comes from the IM platform, not us.
  if kind == :file && !supported_file?(path)
    ext = File.extname(path).delete_prefix(".").downcase
    display_name = name || File.basename(path)
    Clacky::Logger.info("[dingtalk] send_file: unsupported extension .#{ext} (#{display_name})")
    supported_list = ApiClient::SUPPORTED_FILE_EXTS.map { |e| ".#{e}" }.join(", ")
    send_text(
      chat_id,
      %([DingTalk System] ⚠️ Failed to deliver file "#{display_name}": file type ".#{ext}" is not supported. Supported types: #{supported_list}.)
    )
    return { ok: false, error: :unsupported_extension }
  end

  media_id = @api_client.upload_media(path, kind: kind)
  unless media_id
    Clacky::Logger.warn("[dingtalk] send_file: upload failed for #{path}")
    return { ok: false, error: "upload_failed" }
  end

  @api_client.send_media(
    robot_code: route[:robot_code],
    conv_type:  route[:conv_type],
    conv_id:    route[:conv_id],
    user_id:    route[:user_id],
    media_id:   media_id,
    kind:       kind,
    file_name:  name || File.basename(path)
  )
end

#send_text(chat_id, text, reply_to: nil) ⇒ Object

Always sent as markdown so AI replies render rich text (headings, bold, lists, links). DingTalk’s markdown msgtype renders plain text unchanged, so no detection branch is needed.

Parameters:

  • chat_id (String)

    — for DingTalk Stream Mode, chat_id == webhook URL



84
85
86
87
88
89
90
91
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 84

def send_text(chat_id, text, reply_to: nil)
  webhook_url = resolve_webhook(chat_id)
  unless webhook_url
    Clacky::Logger.warn("[dingtalk] no valid sessionWebhook for chat #{chat_id} (expired or never received)")
    return { ok: false, error: "session_webhook_expired" }
  end
  @api_client.send_via_webhook(webhook_url, text, msg_type: :markdown)
end

#start(&on_message) ⇒ Object



64
65
66
67
68
69
70
71
72
73
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 64

def start(&on_message)
  @running    = true
  @on_message = on_message

  @stream_client = StreamClient.new(
    client_id:     @config[:client_id],
    client_secret: @config[:client_secret]
  )
  @stream_client.start { |frame| handle_frame(frame) }
end

#stopObject



75
76
77
78
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 75

def stop
  @running = false
  @stream_client&.stop
end

#validate_config(config) ⇒ Object



146
147
148
149
150
151
# File 'lib/clacky/server/channel/adapters/dingtalk/adapter.rb', line 146

def validate_config(config)
  errors = []
  errors << "client_id is required"     if config[:client_id].to_s.strip.empty?
  errors << "client_secret is required" if config[:client_secret].to_s.strip.empty?
  errors
end