一般数据保护条例 (GDPR) 是欧洲关于数据保护和隐私的法规。它授予个人权利要求删除他们的个人数据,也称为删除权。如果您存储任何 个人识别信息 (PII) 的用户,例如他们的用户ID,在收到用户请求后,您必须遵守GDPR要求,通过删除此信息来删除此信息。
而不是手动处理请求,你可以设置一个 webhook并使用第三方消息应用程序内的机器人来自动化流程。由于数据存储是存储个人信息数据的最常用方式,本教程提供了如何在Guilded或Discord中创建一个使用开放云API数据存储来删除个人信息数据的自动化解决方案的示例。
工作流
完成本教程后,您应该能够创建一个本地运行的自定义程序,用于自动处理用户的删除请求。该流程的工作流程如下:
- Roblox 支持收到用户的删除请求权限。
- Roblox 网络请求触发,包含用户 ID 和列表的起始地点 ID,用于他们加入的体验。
- 您的机器人倾听这些 Webhook 通知,验证其真实性,并使用 打开云 API 为数据存储库删除存储在数据存储库中的 PII 数据 来删除存储在数据存储库中的 PII 数据。
- 机器人在 Discord 或 Guilded 上回应网络消息以删除状态。

配置第三方集成的 webhook
在创建机器人之前,在第三方消息应用程序上设置一个具有网络钩集成的服务器。然后使用服务器配置创建者仪表板上的 Webhook。
设置服务器
以下步骤显示如何使用 Guilded 或 Discord 设置服务器。
- 创建一个新的公会服务器。如果您对该过程不熟悉,请参阅 Guilded 支持。
- 在 隐私 设置下,将服务器设置为私私人。服务器自动创建一个私有 #general 通道作为你的默认频道。
- 创建一个与新服务器的 Webhook 集成,并给它一个名称,您可以轻松理解,例如 GDPR Hook。如果您对该过程不熟悉,请参阅公会支持。
- 复制 webhook URL 并将其存储在安全位场景。仅允许信任的团队成员访问它,因为泄露 URL 可能会启用恶意行为者发送假消息并可能删除您的用户数据。
在 Roblox 上配置一个 webhook
获取第三方服务器 URL 后,使用它来在创建者仪表板上配置 网络钩。确保您执行以下设置:
- 将公会或 Discord 服务器 URL 添加为 网络请求 URL 。
- 包含自定义的 秘密 。虽然秘密对完成配置是可选的,但你应该包含一个以防止恶意行为者伪造 Roblox 并删除你的数据。了解有关秘密使用的更多信息,请参阅验证Webhook安全。
- 在 触发 下选择 删除请求权 。
您可以使用 测试响应 按钮测试 webhook,看看您是否在 Roblox 的 #general 通道收到通知。如果您未收到通知,请重试或检查服务器设置以解决错误。

配置机器人
在添加 webhook 之后,使用它来配置机器人以下步骤:
通过单击其图标打开 所有服务器 列表,或使用快捷键方式:
- CtrlS 在 Windows 上。
- ⌘ S 在 Mac 上。
选择您的服务器接收删除通知权限。
扩展 服务器首页 下的列表,然后选择 管理机器人 。
服务器自动创建一个私有 #general 通道作为你的默认频道。
点击 创建机器人 按钮,并添加机器人名称。公会将你重定向到机器人配置页面。
在机器人配置页面上选择 API 部分。
在 代币 部分下,单击 生成代币 按钮。
保存并存储生成的代币在安全的场景方。
创建开放云 API 钥键
要允许您的第三方机器人访问您的数据存储以存储用户的PII数据,创建一个开放云API钥匙,可以访问您的体验并添加数据存储的 删除入口 权限以进行数据删除。如果您使用订阅数据存储来存储 个人识别信息,您还需要添加订阅数据存储的 写入 权限。完成后,复制并保存 API 钥匙到安全位置,以便在后续步骤中使用。
获取体验和地点的标识符
为了让机器人找到用户请求删除的 PII 数据,获取以下标识符所有体验,您打算使用机器人的:
- 宇宙ID ,您体验的唯一标识。
- 开始位置ID ,体验开始位置的唯一标识。
要获得这些标识符:
导航到 创建者仪表板。
将鼠标悬停在体验缩略图上,单击 ⋯ 按钮,然后分别选择 复制宇宙ID 和 复制起始位置ID 。
添加脚本
在完成设置数据存储的 webhook、机器人和 API 钥匙后,将它们添加到实现机器人自动化逻辑的脚本中。以下示例使用了 Python 3:
使用以下命令安装 Python 库:
安装库存pip3 install guilded.py==1.8.0pip3 install requestspip3 install urllib3==1.26.6复制并保存以下脚本,对应于机器人逻辑的不同部分在同一目录中:
bot_config.pyBOT_TOKEN = ""OPEN_CLOUD_API_KEY = ""ROBLOX_WEBHOOK_SECRET = ""# 从开始位置ID到词典# (宇宙ID、数据存储名单、范围和入口键列表) 对# 标准数据存储# 存储在这些条目下的用户数据将被删除STANDARD_DATA_STORE_ENTRIES = {# 开始地点 ID111111111: (# 宇宙ID222222222,[("StandardDataStore1", "Scope1", "Key1_{user_id}"),("StandardDataStore1", "Scope1", "Key2_{user_id}"),("StandardDataStore2", "Scope1", "Key3_{user_id}")]),33333333: (444444444,[("StandardDataStore3", "Scope1", "Key1_{user_id}")])}# 从开始位置ID到词典# (宇宙ID、数据存储名单、范围和入口键列表) 对# 排序数据存储# 存储在这些条目下的用户数据将被删除ORDERED_DATA_STORE_ENTRIES = {111111111: (222222222,[("OrderedDataStore1", "Scope2", "Key4_{user_id}")])}数据_存储_api.pyimport requestsimport bot_configfrom collections import defaultdict"""Calls Data Stores Open Cloud API to delete all entries for a user_id configured inSTANDARD_DATA_STORE_ENTRIES. Returns a list of successful deletions and failures to delete."""def delete_standard_data_stores(user_id, start_place_ids):successes = defaultdict(list)failures = defaultdict(list)for owned_start_place_id in bot_config.STANDARD_DATA_STORE_ENTRIES:if owned_start_place_id not in start_place_ids:continueuniverse_id, universe_entries = bot_config.STANDARD_DATA_STORE_ENTRIES[owned_start_place_id]for (data_store_name, scope, entry_key) in universe_entries:entry_key = entry_key.replace("{user_id}", user_id)response = requests.delete(f"https://apis.roblox.com/datastores/v1/universes/{universe_id}/standard-datastores/datastore/entries/entry",headers={"x-api-key": bot_config.OPEN_CLOUD_API_KEY},params={"datastoreName": data_store_name,"scope": scope,"entryKey": entry_key})if response.status_code in [200, 204]:successes[owned_start_place_id].append((data_store_name, scope, entry_key))else:failures[owned_start_place_id].append((data_store_name, scope, entry_key))return successes, failures"""Calls Ordered Data Stores Open Cloud API to delete all entries for a user_id configured inORDERED_DATA_STORE_ENTRIES. Returns a list of successful deletions and failures to delete."""def delete_ordered_data_stores(user_id, start_place_ids):successes = defaultdict(list)failures = defaultdict(list)for owned_start_place_id in bot_config.ORDERED_DATA_STORE_ENTRIES:if owned_start_place_id not in start_place_ids:continueuniverse_id, universe_entries = bot_config.ORDERED_DATA_STORE_ENTRIES[owned_start_place_id]for (data_store_name, scope, entry_key) in universe_entries:entry_key = entry_key.replace("{user_id}", user_id)response = requests.delete(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{universe_id}/orderedDatastores/{data_store_name}/scopes/{scope}/entries/{entry_key}",headers={"x-api-key": bot_config.OPEN_CLOUD_API_KEY})if response.status_code in [200, 204, 404]:successes[owned_start_place_id].append((data_store_name, scope, entry_key))else:failures[owned_start_place_id].append((data_store_name, scope, entry_key))return successes, failuresmessage_parser.pyimport timeimport hmacimport hashlibimport reimport base64import bot_config"""Parses received message for Roblox signature and timestamp, the footer is only set if youconfigured webhook secret"""def parse_footer(message):if not message.embeds[0].footer or \not message.embeds[0].footer.text:return "", 0footer_match = re.match(r"Roblox-Signature: (.*), Timestamp: (.*)",message.embeds[0].footer.text)if not footer_match:return "", 0else:signature = footer_match.group(1)timestamp = int(footer_match.group(2))return signature, timestamp"""Verifies Roblox signature with configured secret to check for validity"""def validate_signature(message, signature, timestamp):if not message or not signature or not timestamp:return False# 在 300 秒窗口内防止重播攻击request_timestamp_ms = timestamp * 1000window_time_ms = 300 * 1000oldest_timestamp_allowed = round(time.time() * 1000) - window_time_msif request_timestamp_ms < oldest_timestamp_allowed:return False# 验证签名timestamp_message = "{}.{}".format(timestamp, message.embeds[0].description)digest = hmac.new(bot_config.ROBLOX_WEBHOOK_SECRET.encode(),msg=timestamp_message.encode(),digestmod=hashlib.sha256).digest()validated_signature = base64.b64encode(digest).decode()if signature != validated_signature:return False# 有效签名return True"""Parses a received webhook messaged on Discord or Guilded. Extracts user ID, prevents replay attackbased on timestamp received, and verifies Roblox signature with configured secret to check forvalidity."""def parse_message(message):# 解析收到用户 ID 和游戏 ID 的消息if len(message.embeds) != 1 or \not message.embeds[0].description:return "", []description_match = re.match(r"You have received a new notification for Right to Erasure for the User Id: (.*) in " +r"the game\(s\) with Ids: (.*)",message.embeds[0].description)if not description_match:return "", []user_id = description_match.group(1)start_place_ids = set(int(item.strip()) for item in description_match.group(2).split(","))signature, timestamp = parse_footer(message)if validate_signature(message, signature, timestamp):return user_id, start_place_idselse:return "", []guilded_bot.pyimport guildedimport jsonimport bot_configimport data_stores_apiimport message_parserdef run():client = guilded.Client()@client.eventasync def on_ready():print(f"{client.user} is listening to Right to Erasure messages")"""Handler for webhook messages from Roblox"""@client.eventasync def on_message(message):# 解析并验证消信息user_id, start_place_ids = message_parser.parse_message(message)if not user_id or not start_place_ids:return# 删除标准数据存储用户数据[successes, failures] = data_stores_api.delete_standard_data_stores(user_id, start_place_ids)if successes:await message.reply(f"Deleted standard data stores data for " +f"user ID: {user_id}, data: {dict(successes)}")if failures:await message.reply(f"Failed to delete standard data stores data for " +f"user ID: {user_id}, data: {dict(failures)}")# 删除订阅的数据存储用户数据[successes, failures] = data_stores_api.delete_ordered_data_stores(user_id, start_place_ids)if successes:await message.reply(f"Deleted ordered data stores data for " +f"user ID: {user_id}, data: {dict(successes)}")if failures:await message.reply(f"Failed to delete ordered data stores data for " +f"user ID: {user_id}, data: {dict(failures)}")client.run(bot_config.BOT_TOKEN)if __name__ == "__main__":run()在 bot_config.py 文件中对机器人的主配置:
- 将 BOT_TOKEN 设置为你的机器人生成的代币。
- 将 OPEN_CLOUD_API_KEY 设置为你创建的 API 密钥。
- 将 ROBLOX_WEBHOOK_SECRET 设置为在创作中心板配置网络钩时设置的秘密。
- 在 STANDARD_DATA_STORE_ENTRIES 和 ORDERED_DATA_STORE_ENTRIES 词典中找到每个记录的数据存储以删除:
- 将复制的起始地点 ID 添加为钥匙。
- 将宇宙ID添加为 tuple 值的第一个元素。
- 用数据存储的名称、范围、入口键名称和关联的用户 ID 替换 tuple 的第二个元素。如果您使用不同的数据模型,则应根据自己的数据模型进行修改,以便匹配。
执行以下命令以运行机器人:
运行公会机器人python3 guilded_bot.py机器人然后开始收听并验证 Roblox 网络请求的 webhook,并调用 Open Cloud 端点删除相应的数据存商店。
测试
您可以创建并运行测试消息来验证您的自定义程序可以正确处理擦除请求和删除 PII 数据:
向您的公会或 Discord 网络请求服务器发送 HTTP POST 请求,带有以下请求身体:
示例请求curl -X POST {serverUrl}-H 'Content-Type: application/json'-d '{"embeds":[{"title":"RightToErasureRequest","description":"You have received a new notification for Right to Erasure for the User Id: {userIds} in the game(s) with Ids: {gameIds}","footer":{"icon_url":"https://create.roblox.com/dashboard/assets/webhooks/roblox_logo_metal.png","text":"Roblox-Signature: {robloxSignature}, Timestamp: {timestamp}"}}]}'如果你有一个 webhook 秘密:
- 通过将 HMAC-SHA256 编码应用到您的 webhook 秘密键来生成一个 Roblox-Signature 。
- 使用 UTC 时戳秒数设置当前时间为 Timestamp .
将 description 放在以下格式中:
Description Field Format{Timestamp}. You have received a new notification for Right to Erasure for the User Id: {userId} in the game(s) with Ids: {gameIds}`.例如:
Example Description Field1683927229. You have received a new notification for Right to Erasure for the User Id: 2425654247 in the game(s) with Ids: 10539205763, 13260950955
你的程序应该能够识别出你的消息来自官方 Roblox 源,因为你用秘密编码了消息。然后应该删除与您的请求相关的 PII 数据。
示例身体
{
"embeds": [
{
"title": "RightToErasureRequest",
"description": "You have received a new notification for Right to Erasure for the User Id: 2425654247 in the game(s) with Ids: 10539205763, 13260950955",
"footer": {
"icon_url": "https://创建 or 创作reate.roblox.com/ashboard/assets/webhooks/roblox_logo_metal.png",
"text": "Roblox-Signature: UIe6GJ78MHCmU/zUKBYP3LV0lAqwWRFR6UEfPt1xBFw=, Timestamp: 1683927229"
}
}
]
}