为 Discourse AI 的 artifact 键值更新添加 webhook/事件支持(或允许管理员禁用沙箱)

我想提议允许 AI 工件在存储的数据更新时发送 Webhook。这样做有很多好处,但我会尽量简明扼要地阐述我的理由。

许多使用 Discourse 的组织也同时使用各种自动化工具,这些工具正变得越来越流行、易于设置且对新手友好。AI 工件可以包含宝贵数据,这些数据可以非常有效地用于此类自动化,而且额外的优势是它们已经是 JSON 格式!

就我而言,我在 Docker Compose 环境中使用 n8n,并将其与 Discourse 容器并列部署。我已经利用它自动化了各种事务,包括通过 Docker 网络对 Discourse 实例进行操作。然而,我维护的其中一个 Discourse 实例是为一个教育组织/企业服务的,其中使用 AI 工件来追踪学习者的笔记、课程日志等。这类数据对于通过 n8n 等自动化工具改善教师工作流程、生成摘要等将非常有益。

从技术上讲,轮询工件以获取更新是可行的,但这很容易开始消耗系统资源,充其量也是一种资源浪费,而且需要 extensive 的技术技能来设置,而许多管理员并不具备这些技能。

不用说,这对 Discourse 的企业客户也将非常有益。

一种变通方法可能是让工件的 JavaScript 在调用 window.discourseArtifact.set(...) 后调用外部 Webhook。

然而,这种方法存在一些局限性:

  1. 它运行在浏览器端,因此不具备权威性。

  2. 仅当工件 JavaScript 在用户浏览器中成功执行时才会触发。

  3. 可能受到 CORS、浏览器隐私工具、网络拦截器或沙箱限制的影响。

  4. 除非构建额外的服务器端验证,否则无法信任载荷确实来自 Discourse。

  5. 无法安全地将密钥嵌入到工件的 JavaScript 中。

服务器端事件/Webhook 将更加可靠和安全。

[details=“我不是 Ruby 开发者,所以我一直与 GPT-5.5 讨论此事,它也提供了一些有趣的见解……”]
基于 Discourse 当前的 Webhook 架构,最干净的实现可能不是在 AI 工件内部定制一个“调用此 URL”的功能。它应该被实现为标准的 Discourse Webhook 事件类型,并由标准的内部 DiscourseEvent 提供支持。

最佳实现形态

我建议 Discourse 开发人员分两层来实现:

  1. 内部事件:在工件 KV 记录变更时发出。

  2. Webhook 事件类型:订阅这些内部事件,并使用现有的 Webhook 投递系统。

这样可以保持与 Discourse 其余部分的一致性。现有的 Webhook 通过将内部 DiscourseEvent 映射到 config/initializers/012-web_hook_events.rb 中的 WebHook.enqueue_* 调用来工作;例如,主题、帖子、用户、分类、标签、可审核项、通知和点赞事件都是这样连接的。

工件存储路径非常简单:ArtifactKeyValuesController#set 查找或初始化键值记录,分配 key/value/public 并保存;destroy 根据键查找当前用户的记录并销毁它。模型本身是 AiArtifactKeyValue,属于一个工件和一个用户,包含 keyvaluepublic 字段,并强制执行按工件/用户/键的唯一性。

建议的事件名称

我可能会使用三个特定的 Webhook 事件:

ai_artifact_key_value_created
ai_artifact_key_value_updated
ai_artifact_key_value_deleted

或者,单个事件也可以工作:

ai_artifact_key_value_changed

……但三个事件更符合 Discourse 现有的风格。Discourse 已经有单独的 Webhook 事件名称,如 post_createdpost_editedpost_destroyedcalendar_event_createdcalendar_event_updated 等等。

它们可能涉及的文件/类

1. 添加新的 Webhook 事件类型

WebHookEventType 目前定义数值常量、一个 group 枚举以及一个从事件名称到 ID 的 TYPES 哈希。

他们可以添加类似以下内容:

AI_ARTIFACT = 20

enum :group,
  {
    # 现有组...
    ai_artifact: 18,
  },
  scopes: false

TYPES = {
  # 现有类型...
  ai_artifact_key_value_created: 2001,
  ai_artifact_key_value_updated: 2002,
  ai_artifact_key_value_deleted: 2003,
}

具体的 ID 由 Discourse 维护者决定;他们只需要避免冲突即可。

他们还需要在 db/fixtures/007_web_hook_event_types.rb 中添加种子条目,因为现有的 Webhook 事件类型是在那里使用 ID、名称和组进行种子的。

示例:

WebHookEventType.seed do |b|
  b.id = WebHookEventType::TYPES[:ai_artifact_key_value_created]
  b.name = "ai_artifact_key_value_created"
  b.group = WebHookEventType.groups[:ai_artifact]
end

WebHookEventType.seed do |b|
  b.id = WebHookEventType::TYPES[:ai_artifact_key_value_updated]
  b.name = "ai_artifact_key_value_updated"
  b.group = WebHookEventType.groups[:ai_artifact]
end

WebHookEventType.seed do |b|
  b.id = WebHookEventType::TYPES[:ai_artifact_key_value_deleted]
  b.name = "ai_artifact_key_value_deleted"
  b.group = WebHookEventType.groups[:ai_artifact]
end

管理员 Webhook 界面随后应自动识别这些事件,因为 Admin::WebHooksController#index 已经为界面序列化分组的活跃事件类型。事件类型序列化器已经暴露了 idnamegroup

2. 当 Discourse AI 禁用时隐藏这些事件

WebHookEventType.active 已经会在功能禁用时隐藏依赖插件的 Webhook 事件,例如 solved、assign、主题投票、聊天和日历事件。

因此,他们可以添加:

unless defined?(SiteSetting.discourse_ai_enabled) && SiteSetting.discourse_ai_enabled
  ids_to_exclude.concat(
    [
      TYPES[:ai_artifact_key_value_created],
      TYPES[:ai_artifact_key_value_updated],
      TYPES[:ai_artifact_key_value_deleted],
    ],
  )
end

他们可能还需要检查是否存在或已添加的特定于工件的设置。

3. 从模型而非仅仅是控制器发出内部事件

虽然当前的写入路径是基于控制器的,但更稳健的位置可能是模型,使用提交回调:

class AiArtifactKeyValue < ActiveRecord::Base
  after_create_commit :trigger_created_event
  after_update_commit :trigger_updated_event
  after_destroy_commit :trigger_deleted_event

  private

  def trigger_created_event
    DiscourseEvent.trigger(:ai_artifact_key_value_created, self)
  end

  def trigger_updated_event
    return if previous_changes.slice("value", "public").blank?

    DiscourseEvent.trigger(:ai_artifact_key_value_updated, self)
  end

  def trigger_deleted_event
    DiscourseEvent.trigger(:ai_artifact_key_value_deleted, self)
  end
end

模型级别的回调也将捕获未来的写入路径,而不仅仅是 ArtifactKeyValuesController#setafter_commit 部分很重要,因为 Webhook 投递只有在数据库更改安全提交后才应排队。

一个细微之处:他们可能不应在工件以相同的值调用 set() 且实际上未发生任何更改时触发“更新”事件。检查 previous_changes 可以避免产生嘈杂的 Webhook。

4. 添加 Webhook 载荷序列化器

Discourse Webhook 通过序列化器生成载荷。通用的 WebHook.enqueue_object_hooks 方法可以接受一个序列化器,而 WebHook.generate_payload 使用系统用户守卫对对象进行序列化。

他们可以添加类似以下内容:

class WebHookAiArtifactKeyValueSerializer < ApplicationSerializer
  attributes :id,
             :ai_artifact_id,
             :post_id,
             :topic_id,
             :user_id,
             :key,
             :public,
             :value_included,
             :created_at,
             :updated_at

  def post_id
    object.ai_artifact.post_id
  end

  def topic_id
    object.ai_artifact.post.topic_id
  end

  def value_included
    false
  end
end

我强烈建议默认不包含 value。工件 KV 数据可以是每个用户私有的,且模型有一个 public 标志。如果 Discourse 希望支持值,他们可以明确指定:

include_value: false
include_public_values: true
include_private_values: false

但安全的 v1 版本可能应该完全省略值。

5. 将内部事件连接到 Webhook 投递

他们可以在 config/initializers/012-web_hook_events.rb 中添加处理程序,模仿现有模式。

类似以下内容:

%i[
  ai_artifact_key_value_created
  ai_artifact_key_value_updated
  ai_artifact_key_value_deleted
].each do |event|
  DiscourseEvent.on(event) do |key_value|
    artifact = key_value.ai_artifact
    post = artifact.post
    topic = post.topic

    payload =
      WebHook.generate_payload(
        :ai_artifact_key_value,
        key_value,
        WebHookAiArtifactKeyValueSerializer
      )

    WebHook.enqueue_hooks(
      :ai_artifact_key_value,
      event,
      id: key_value.id,
      category_id: topic&.category_id,
      tag_ids: topic&.tags&.pluck(:id),
      payload: payload
    )
  end
end

这将重用现有的 Webhook 作业管道。WebHook.enqueue_hooks 查找该事件的活跃 Webhook 并排队 Jobs::EmitWebHookEvent。该作业已经处理投递、重试、日志记录、标头、签名和管理员可见性。

包含 category_idtag_ids 是个不错的细节,因为现有的 Webhook 作业可以按分类和标签进行过滤。Webhook 作业在发送前已经检查了分类和标签约束。由于 AI 工件属于一个帖子,而模型中工件也属于一个帖子,因此推导分类/主题上下文应该是可行的。

投递的 Webhook 可能长什么样

由于 Discourse 的 Webhook 正文使用 event_type 作为顶层根,这可能看起来像:

{
  "ai_artifact_key_value": {
    "id": 456,
    "ai_artifact_id": 123,
    "post_id": 789,
    "topic_id": 321,
    "user_id": 42,
    "key": "score",
    "public": true,
    "value_included": false,
    "created_at": "2026-05-26T12:00:00Z",
    "updated_at": "2026-05-26T12:05:00Z"
  }
}

事件名称将位于标头中,与现有的 Webhook 投递一致:

X-Discourse-Event-Type: ai_artifact_key_value
X-Discourse-Event: ai_artifact_key_value_updated
X-Discourse-Event-Signature: sha256=...

这些标头与 EmitWebHookEvent 目前构建 Webhook 标头的方式一致。

开发人员可能需要决定以下几点:

是否应包含值?
我的投票:默认不包含。也许只允许公开值,或者将值包含设为管理员设置。每个用户私有的工件数据不应无声地离开站点。

应该有一个事件还是三个?
对于 Webhook 订阅者来说,三个更清晰。带有一个 action 字段的单个事件在内部更简单。现有的 Discourse 风格倾向于多个事件名称。

是否应应用分类/标签过滤?
我认为是的。工件附加在帖子/主题上,因此分类和标签过滤器是有意义的。

这应该实现在 Discourse 核心中还是 Discourse AI 插件中?
数据模型位于 Discourse AI 插件中,但 Webhook 事件注册表位于核心中。由于捆绑的插件(如 solved/chat/calendar)已经在共享的 WebHookEventType 列表中拥有 Webhook 事件类型,Discourse 可能也愿意在此处添加 AI 工件事件。active 方法可以在 AI 禁用时隐藏它们。

即使不存在 Webhook,是否也应发出内部 DiscourseEvent
是的。这为插件作者提供了一个干净的钩子点,即使与 Webhook 无关。

复杂度

我会将其描述为中等但非常集中

可能的工作包括:

  1. 添加 3 个事件类型常量。

  2. 添加 1 个 Webhook 组。

  3. 添加 3 条种子记录。

  4. 添加 1 个序列化器。

  5. 添加 3 个模型回调或服务级事件触发器。

  6. 添加 Webhook 初始化器处理程序。

  7. 为创建/更新/删除添加测试。

  8. 围绕是否包含 value 做出隐私决策。

投递系统、重试、签名、事件日志、ping/重投界面以及管理员 Webhook 配置已经存在。该功能主要是让工件 KV 变更对现有系统可见。

待 Discourse 自动化新方案 Workflows 落地后再行探讨。

2 个赞

噢,这听起来确实很吸引人。我在 Meta 上是否遗漏了关于此事的任何信息?是否有时间表?