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



120
121
122
123
124
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 120

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
# 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] dropping unsupported msg_type=#{msg_type}")
    return nil
  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 = strip_mentions(content["text"].to_s.strip)
    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, ...}, ...], ...]}


137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/clacky/server/channel/adapters/feishu/message_parser.rb', line 137

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

#strip_mentions(text) ⇒ String

Strip bot @mentions from message text

Parameters:

  • text (String)

Returns:

  • (String)


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

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