Class: Clacky::Channel::Adapters::Feishu::MessageParser

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/server/channel/adapters/feishu/message_parser.rb

Overview

Parses incoming Feishu webhook events into a standardized InboundMessage format.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data) ⇒ MessageParser

Returns a new instance of MessageParser.



21
22
23
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 21

def initialize(data)
  @data = data
end

Class Method Details

.parse(body) ⇒ Hash?

Parse a Feishu webhook event body

Parameters:

  • body (String, Hash)

    Raw webhook body

Returns:

  • (Hash, nil)

    Standardized inbound message, or nil if not a message event



14
15
16
17
18
19
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 14

def self.parse(body)
  data = body.is_a?(Hash) ? body : JSON.parse(body)
  new(data).parse
rescue JSON::ParserError
  nil
end

Instance Method Details

#extract_doc_urls(text) ⇒ Array<String>

Extract Feishu document URLs from text. Matches: /docx/TOKEN, /docs/TOKEN, /wiki/TOKEN

Parameters:

  • text (String)

Returns:

  • (Array<String>)

    matched URLs



128
129
130
131
132
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 128

def extract_doc_urls(text)
  return [] if text.nil? || text.empty?

  text.scan(%r{https?://[a-zA-Z0-9._-]+\.(?:feishu\.cn|larksuite\.com)/(?:docx|docs|wiki)/[A-Za-z0-9_-]+(?:\?[^\s]*)?})
end

#parseHash?

Returns Inbound message or nil.

Returns:

  • (Hash, nil)

    Inbound message or nil



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 26

def parse
  # Handle verification challenge
  if @data["type"] == "url_verification"
    return { type: :challenge, challenge: @data["challenge"] }
  end

  header = @data["header"]
  return nil unless header

  event_type = header["event_type"]

  case event_type
  when "im.message.receive_v1"
    parse_message_event
  else
    nil
  end
end

#parse_message_eventHash?

Parse message.receive event

Returns:

  • (Hash, nil)


48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 48

def parse_message_event
  event = @data["event"]
  return nil unless event

  message = event["message"]
  sender = event["sender"]
  return nil unless message && sender

  msg_type = message["message_type"]
  Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
  unless %w[text image file post].include?(msg_type)
    Clacky::Logger.info("[feishu] unsupported msg_type=#{msg_type}")
    chat_type = message["chat_type"] == "p2p" ? :direct : :group
    return {
      type:               :message,
      platform:           :feishu,
      chat_id:            message["chat_id"],
      chat_type:          chat_type,
      mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
      unsupported:        true
    }
  end

  content_raw = message["content"]
  return nil unless content_raw

  content = JSON.parse(content_raw)
  text = ""
  image_keys = []
  file_attachments = []

  case msg_type
  when "text"
    text = resolve_mentions(content["text"].to_s.strip, message["mentions"])
    return nil if text.empty?
  when "image"
    image_keys = [content["image_key"]].compact
    return nil if image_keys.empty?
  when "file"
    file_key = content["file_key"]
    file_name = content["file_name"]
    return nil unless file_key
    file_attachments = [{ key: file_key, name: file_name.to_s }]
  when "post"
    parsed = parse_post_content(content)
    text = parsed[:text]
    image_keys = parsed[:image_keys]
    return nil if text.empty? && image_keys.empty?
  end

  chat_id = message["chat_id"]
  message_id = message["message_id"]
  user_id = sender.dig("sender_id", "open_id")
  chat_type = message["chat_type"] == "p2p" ? :direct : :group
  create_time = message["create_time"]&.to_i
  timestamp = create_time ? Time.at(create_time / 1000.0) : Time.now

  {
    type: :message,
    platform: :feishu,
    chat_id: chat_id,
    user_id: user_id,
    text: text,
    image_keys: image_keys,
    file_attachments: file_attachments,
    doc_urls: extract_doc_urls(text),
    message_id: message_id,
    timestamp: timestamp,
    chat_type: chat_type,
    mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
    raw: @data
  }
rescue JSON::ParserError
  nil
end

#parse_post_content(content) ⇒ Object

Parse a Feishu post content body into text and image_keys. post content structure from event payloads:

{"title": "", "content": [[{tag, text, ...}, ...], ...]}


157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 157

def parse_post_content(content)
  rows = content["content"]
  return { text: "", image_keys: [] } unless rows.is_a?(Array)

  text_lines = []
  image_keys = []

  rows.each do |row|
    next unless row.is_a?(Array)
    line_parts = []
    row.each do |element|
      next unless element.is_a?(Hash)
      case element["tag"]
      when "text", "md", "code_block"
        part = element["text"].to_s
        line_parts << part unless part.empty?
      when "a"
        part = element["text"].to_s
        part = element["href"].to_s if part.empty?
        line_parts << part unless part.empty?
      when "img"
        key = element["image_key"].to_s
        image_keys << key unless key.empty?
      when "at"
        # skipped — mention identity resolved via top-level mentions field
      end
    end
    line = line_parts.join.strip
    text_lines << line unless line.empty?
  end

  { text: text_lines.join("\n"), image_keys: image_keys }
end

#resolve_mentions(text, mentions) ⇒ Object

Replace @_user_N placeholders in text with real display names from mentions array. Falls back to stripping unresolved placeholders via strip_mentions.



144
145
146
147
148
149
150
151
152
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 144

def resolve_mentions(text, mentions)
  mapping = Array(mentions).each_with_object({}) do |m, h|
    key  = m["key"].to_s
    name = m.dig("name").to_s
    h[key] = name unless key.empty? || name.empty?
  end
  result = text.gsub(/@_user_\d+/) { |k| mapping[k] ? "@#{mapping[k]}" : k }
  strip_mentions(result).strip
end

#strip_mentions(text) ⇒ String

Strip bot @mentions from message text

Parameters:

  • text (String)

Returns:

  • (String)


137
138
139
140
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 137

def strip_mentions(text)
  # Feishu mentions are formatted as <at user_id="...">Name</at>
  text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
end