Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(platform): Support manually setting up webhooks #8750

Merged
merged 109 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
109 commits
Select commit Hold shift + click to select a range
857ae69
Add `Graph.on_update`, `Block.on_node_update`, `Block.on_node_delete`…
Pwuts Oct 16, 2024
2f405c3
Add credentials injection to `Graph.on_update` etc hooks
Pwuts Oct 17, 2024
921f614
Add webhooks and their links to nodes to DB schema
Pwuts Oct 17, 2024
946b35e
update webhooks db schema
Pwuts Oct 17, 2024
dfe36c1
Introduce `PLATFORM_BASE_URL` config attribute
Pwuts Oct 17, 2024
321ef9d
Add `WebhooksManager` base and GitHub implementation + graph lifecycl…
Pwuts Oct 17, 2024
f4ce0f4
Add webhook ingress endpoint
Pwuts Oct 17, 2024
40e846e
fix(blocks): Allow having an input and output pin with the same name
Pwuts Oct 19, 2024
6dc9e0c
fix bootstrapping issues
Pwuts Oct 19, 2024
ab5b336
hide input pins on webhook blocks
Pwuts Oct 20, 2024
41ccae7
dedup `generateInputHandles`
Pwuts Oct 20, 2024
a82acc7
refactor(backend): Make `RedisEventQueue` generic
Pwuts Oct 20, 2024
36b87eb
apply event type filter in webhook ingress endpoint
Pwuts Oct 21, 2024
13e6a75
add webhook ping endpoint
Pwuts Oct 21, 2024
f117d3f
hide `payload` input
Pwuts Oct 21, 2024
c604d27
fix graph creation
Pwuts Oct 21, 2024
34d82bf
fix enum usage
Pwuts Oct 21, 2024
98aed6d
smol cleanup
Pwuts Oct 21, 2024
9f37671
fix webhook resource string formatting
Pwuts Oct 21, 2024
e5c95f6
add debug stuff for webhooks
Pwuts Oct 21, 2024
5e995f9
fix webhook ingress URL
Pwuts Oct 21, 2024
aa12a5a
fix github events
Pwuts Oct 21, 2024
707df04
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into re…
majdyz Oct 24, 2024
5158b87
[Reduce code change size] Revert CreatableGraph/CreatableNode -> Grap…
majdyz Oct 24, 2024
5e70973
[Reduce code change size] Take Redis generic refactor out of the PR
majdyz Oct 24, 2024
6885fa8
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
majdyz Oct 24, 2024
2734d83
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into re…
majdyz Oct 25, 2024
19b38fb
Fix frontend_base_url / platform_base_url clash
majdyz Oct 25, 2024
1a23591
Set default platform_base_url & fix agent output block bug
majdyz Oct 25, 2024
0343402
Fix CORS issue, make ValueError 400, toast saveAgent HTTP error
majdyz Oct 25, 2024
90d1fc7
Propagate clean error message from github error to user
majdyz Oct 26, 2024
dfeecf4
Propagate clean error message from github error to user
majdyz Oct 26, 2024
089b236
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
majdyz Oct 27, 2024
1ebadb7
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into re…
majdyz Oct 27, 2024
35f044d
Lint
majdyz Oct 27, 2024
3e14f45
Merge remote-tracking branch 'origin/reinier/open-1961-implement-gith…
majdyz Oct 27, 2024
94d7567
Refactor to use new RedisEventBus
majdyz Oct 27, 2024
e9d1b1b
Skip webhook activation on no events
majdyz Oct 28, 2024
4b8cd25
Fix webhook changes breakage
majdyz Oct 28, 2024
2b45c28
Remove already done note
majdyz Oct 28, 2024
843d1cb
Unify channel name format
majdyz Oct 28, 2024
c357ea7
undo removing subgraph stuff
Pwuts Oct 28, 2024
57b532d
fix Node <-> Webhook reference and NodeModel usages
Pwuts Oct 29, 2024
9e0695f
smol clean
Pwuts Oct 29, 2024
02e952c
remove user auth middleware from webhook ingress endpoint
Pwuts Oct 29, 2024
d818af6
fix `add_execution` webhook mechanism
Pwuts Oct 29, 2024
4c68a1c
fix GitHub PR trigger block propagating output from base implementation
Pwuts Oct 29, 2024
e7dc870
move event output from base to PR trigger block
Pwuts Oct 30, 2024
5c82edc
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 4, 2024
dd446a7
change event filter input to a MultiSelect
Pwuts Nov 5, 2024
a525ce6
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 5, 2024
ca942cc
fix registration of pyro (de)serializers on `DatabaseManager`
Pwuts Nov 9, 2024
00882c0
format
Pwuts Nov 9, 2024
a573848
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 9, 2024
1de18a8
format frontend
Pwuts Nov 9, 2024
b1fac14
fix `NodeMultiSelectInput` click behavior
Pwuts Nov 9, 2024
d4c5e63
fix `NodeMultiSelectInput` scroll behavior
Pwuts Nov 9, 2024
ce56136
add docs for webhook-triggered blocks
Pwuts Nov 9, 2024
2a32b3b
fix collapsers?
Pwuts Nov 9, 2024
90418e0
docs: wording & formatting
Pwuts Nov 10, 2024
f40aef8
formatting
Pwuts Nov 11, 2024
8f708a2
fix code block titles
Pwuts Nov 11, 2024
35ffa09
add instruction to add event filter
Pwuts Nov 11, 2024
4048cc8
formatting
Pwuts Nov 11, 2024
060b1bc
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 12, 2024
b7c7028
fix pre-commit isort
Pwuts Nov 12, 2024
9e4f657
fix CI
Pwuts Nov 12, 2024
bdc3590
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 12, 2024
63a41f7
make `GithubPullRequestTriggerBlock` test an actual test
Pwuts Nov 13, 2024
865ac4b
address feedback
Pwuts Nov 13, 2024
fd0a3c2
add instructions for `PLATFORM_BASE_URL` to .env.example
Pwuts Nov 13, 2024
3ae2128
move github example payload
Pwuts Nov 15, 2024
5bb9e91
fix(backend): Add migrations to fix credentials inputs
Pwuts Nov 15, 2024
5ec5834
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 18, 2024
9e16e74
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 19, 2024
bc79d3b
Remove `ProviderName` enum
Pwuts Nov 19, 2024
4a5b783
Improve input descriptions and fix tooltips on GitHub Pull Request Tr…
Pwuts Nov 21, 2024
4c53eb3
Rename `sender` output to `triggered_by_user`
Pwuts Nov 21, 2024
7c0b336
Update description of `triggered_by_user`
Pwuts Nov 21, 2024
0f7fa4b
Fix description of `payload` output to be generic
Pwuts Nov 21, 2024
2dcbcfe
Add `pull_request_url` output
Pwuts Nov 21, 2024
397ae0b
Merge branch 'dev' into reinier/open-1961-implement-github-on-pull-re…
Pwuts Nov 21, 2024
8761c03
Remove webhooks before deleting credentials
Pwuts Nov 21, 2024
6eb643d
Prevent use of webhook system if `PLATFORM_BASE_URL` is not set
Pwuts Nov 21, 2024
0ac0ac9
feat: wip eod commit
ntindle Nov 24, 2024
463c8a6
Update triggers.py
ntindle Nov 24, 2024
23ab904
Merge branch 'dev' into exploration/other-types-of-hooks
ntindle Nov 25, 2024
0eb5d03
Merge branch 'dev' into exploration/other-types-of-hooks
ntindle Dec 3, 2024
f518c56
other: eod commit
ntindle Dec 3, 2024
9d53f08
Add support for webhook blocks with manual set-up
Pwuts Dec 4, 2024
90a9c65
Merge branch 'dev' into exploration/other-types-of-hooks
Pwuts Dec 4, 2024
7c84e0c
fix `on_node_activate`
Pwuts Dec 4, 2024
2715a04
fix `on_node_activate` (vol. 2)
Pwuts Dec 4, 2024
db59b96
Add `webhook_ingress_url` helper function
Pwuts Dec 4, 2024
bb00bf9
fix `validate_graph` for webhook blocks
Pwuts Dec 4, 2024
fa0480e
ADD FIXME TO FIX THE UX BEING FUCKED
Pwuts Dec 4, 2024
0be7edd
Merge branch 'dev' into exploration/other-types-of-hooks
ntindle Dec 6, 2024
088b131
fix manual webhook UX on graph save
Pwuts Dec 9, 2024
b36a282
Merge branch 'dev' into exploration/other-types-of-hooks
Pwuts Dec 11, 2024
5a24f23
refactor: Split compass and base manual webhook handlers
Pwuts Dec 11, 2024
de39057
feat(frontend): Show webhook URL on block for manual webhooks
Pwuts Dec 11, 2024
c54c52a
fix webhook URL UI
Pwuts Dec 12, 2024
9eb7eae
Merge branch 'dev' into exploration/other-types-of-hooks
ntindle Dec 12, 2024
912172f
fix: better descriptions
ntindle Dec 12, 2024
6ffef4e
fix(frontend): Fix webhook status indicator
Pwuts Dec 17, 2024
8e25b99
Merge branch 'dev' into exploration/other-types-of-hooks
Pwuts Dec 17, 2024
55d3cc7
format
Pwuts Dec 17, 2024
e06fb5b
fix(backend): Remove webhook info in graph export
Pwuts Dec 18, 2024
0a456b4
Merge branch 'dev' into exploration/other-types-of-hooks
ntindle Dec 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions autogpt_platform/backend/backend/blocks/compass/triggers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from pydantic import BaseModel

from backend.data.block import (
Block,
BlockCategory,
BlockManualWebhookConfig,
BlockOutput,
BlockSchema,
)
from backend.data.model import SchemaField
from backend.integrations.webhooks.compass import CompassWebhookType


class Transcription(BaseModel):
text: str
speaker: str
end: float
start: float
duration: float


class TranscriptionDataModel(BaseModel):
date: str
transcription: str
transcriptions: list[Transcription]


class CompassAITriggerBlock(Block):
ntindle marked this conversation as resolved.
Show resolved Hide resolved
class Input(BlockSchema):
payload: TranscriptionDataModel = SchemaField(hidden=True)

class Output(BlockSchema):
transcription: str = SchemaField(
description="The contents of the compass transcription."
)

def __init__(self):
super().__init__(
id="9464a020-ed1d-49e1-990f-7f2ac924a2b7",
description="This block will output the contents of the compass transcription.",
categories={BlockCategory.HARDWARE},
ntindle marked this conversation as resolved.
Show resolved Hide resolved
input_schema=CompassAITriggerBlock.Input,
output_schema=CompassAITriggerBlock.Output,
webhook_config=BlockManualWebhookConfig(
provider="compass",
webhook_type=CompassWebhookType.TRANSCRIPTION,
),
test_input=[
{"input": "Hello, World!"},
{"input": "Hello, World!", "data": "Existing Data"},
],
# test_output=[
# ("output", "Hello, World!"), # No data provided, so trigger is returned
# ("output", "Existing Data"), # Data is provided, so data is returned.
# ],
)

def run(self, input_data: Input, **kwargs) -> BlockOutput:
yield "transcription", input_data.payload.transcription
86 changes: 57 additions & 29 deletions autogpt_platform/backend/backend/data/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class BlockType(Enum):
OUTPUT = "Output"
NOTE = "Note"
WEBHOOK = "Webhook"
WEBHOOK_MANUAL = "Webhook (manual)"
AGENT = "Agent"


Expand All @@ -57,6 +58,7 @@ class BlockCategory(Enum):
COMMUNICATION = "Block that interacts with communication platforms."
DEVELOPER_TOOLS = "Developer tools such as GitHub blocks."
DATA = "Block that interacts with structured data."
HARDWARE = "Block that interacts with hardware."
ntindle marked this conversation as resolved.
Show resolved Hide resolved
AGENT = "Block that interacts with other agents."
CRM = "Block that interacts with CRM services."

Expand Down Expand Up @@ -197,7 +199,12 @@ class EmptySchema(BlockSchema):


# --8<-- [start:BlockWebhookConfig]
class BlockWebhookConfig(BaseModel):
class BlockManualWebhookConfig(BaseModel):
"""
Configuration model for webhook-triggered blocks on which
the user has to manually set up the webhook at the provider.
"""

provider: str
"""The service provider that the webhook connects to"""

Expand All @@ -208,26 +215,36 @@ class BlockWebhookConfig(BaseModel):
Only for use in the corresponding `WebhooksManager`.
"""

resource_format: str
event_filter_input: str = ""
"""
Template string for the resource that a block instance subscribes to.
Fields will be filled from the block's inputs (except `payload`).
Example: `f"{repo}/pull_requests"` (note: not how it's actually implemented)
Only for use in the corresponding `WebhooksManager`.
Name of the block's event filter input.
Leave empty if the corresponding webhook doesn't have distinct event/payload types.
"""

event_filter_input: str
"""Name of the block's event filter input."""

event_format: str = "{event}"
"""
Template string for the event(s) that a block instance subscribes to.
Applied individually to each event selected in the event filter input.
Example: `"pull_request.{event}"` -> `"pull_request.opened"`
"""


class BlockWebhookConfig(BlockManualWebhookConfig):
"""
Configuration model for webhook-triggered blocks for which
the webhook can be automatically set up through the provider's API.
"""

resource_format: str
"""
Template string for the resource that a block instance subscribes to.
Fields will be filled from the block's inputs (except `payload`).
Example: `f"{repo}/pull_requests"` (note: not how it's actually implemented)
Only for use in the corresponding `WebhooksManager`.
"""
# --8<-- [end:BlockWebhookConfig]


Expand All @@ -247,7 +264,7 @@ def __init__(
disabled: bool = False,
static_output: bool = False,
block_type: BlockType = BlockType.STANDARD,
webhook_config: Optional[BlockWebhookConfig] = None,
webhook_config: Optional[BlockWebhookConfig | BlockManualWebhookConfig] = None,
):
"""
Initialize the block with the given schema.
Expand Down Expand Up @@ -278,27 +295,38 @@ def __init__(
self.contributors = contributors or set()
self.disabled = disabled
self.static_output = static_output
self.block_type = block_type if not webhook_config else BlockType.WEBHOOK
self.block_type = block_type
self.webhook_config = webhook_config
self.execution_stats = {}

if self.webhook_config:
# Enforce shape of webhook event filter
event_filter_field = self.input_schema.model_fields[
self.webhook_config.event_filter_input
]
if not (
isinstance(event_filter_field.annotation, type)
and issubclass(event_filter_field.annotation, BaseModel)
and all(
field.annotation is bool
for field in event_filter_field.annotation.model_fields.values()
)
):
raise NotImplementedError(
f"{self.name} has an invalid webhook event selector: "
"field must be a BaseModel and all its fields must be boolean"
)
if isinstance(self.webhook_config, BlockWebhookConfig):
# Enforce presence of credentials field on auto-setup webhook blocks
if CREDENTIALS_FIELD_NAME not in self.input_schema.model_fields:
raise TypeError(
"credentials field is required on auto-setup webhook blocks"
)
self.block_type = BlockType.WEBHOOK
else:
self.block_type = BlockType.WEBHOOK_MANUAL

# Enforce shape of webhook event filter, if present
if self.webhook_config.event_filter_input:
event_filter_field = self.input_schema.model_fields[
self.webhook_config.event_filter_input
]
if not (
isinstance(event_filter_field.annotation, type)
and issubclass(event_filter_field.annotation, BaseModel)
and all(
field.annotation is bool
for field in event_filter_field.annotation.model_fields.values()
)
):
raise NotImplementedError(
f"{self.name} has an invalid webhook event selector: "
"field must be a BaseModel and all its fields must be boolean"
)

# Enforce presence of 'payload' input
if "payload" not in self.input_schema.model_fields:
Expand Down
57 changes: 36 additions & 21 deletions autogpt_platform/backend/backend/data/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def is_triggered_by_event_type(self, event_type: str) -> bool:
raise ValueError(f"Block #{self.block_id} not found for node #{self.id}")
if not block.webhook_config:
raise TypeError("This method can't be used on non-webhook blocks")
if not block.webhook_config.event_filter_input:
return True
event_filter = self.input_default.get(block.webhook_config.event_filter_input)
if not event_filter:
raise ValueError(f"Event filter is not configured on node #{self.id}")
Expand Down Expand Up @@ -268,11 +270,19 @@ def sanitize(name):
+ [sanitize(link.sink_name) for link in input_links.get(node.id, [])]
)
for name in block.input_schema.get_required_fields():
if name not in provided_inputs and (
for_run # Skip input completion validation, unless when executing.
or block.block_type == BlockType.INPUT
or block.block_type == BlockType.OUTPUT
or block.block_type == BlockType.AGENT
if (
name not in provided_inputs
and not (
name == "payload"
and block.block_type
in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
)
and (
for_run # Skip input completion validation, unless when executing.
or block.block_type == BlockType.INPUT
or block.block_type == BlockType.OUTPUT
or block.block_type == BlockType.AGENT
)
):
raise ValueError(
f"Node {block.name} #{node.id} required input missing: `{name}`"
Expand All @@ -292,7 +302,6 @@ def has_value(name):

# Validate dependencies between fields
for field_name, field_info in input_schema.items():

# Apply input dependency validation only on run & field with depends_on
json_schema_extra = field_info.json_schema_extra or {}
dependencies = json_schema_extra.get("depends_on", [])
Expand Down Expand Up @@ -359,7 +368,7 @@ def is_static_output_block(nid: str) -> bool:
link.is_static = True # Each value block output should be static.

@staticmethod
def from_db(graph: AgentGraph, hide_credentials: bool = False):
def from_db(graph: AgentGraph, for_export: bool = False):
return GraphModel(
id=graph.id,
user_id=graph.userId,
Expand All @@ -369,7 +378,7 @@ def from_db(graph: AgentGraph, hide_credentials: bool = False):
name=graph.name or "",
description=graph.description or "",
nodes=[
GraphModel._process_node(node, hide_credentials)
NodeModel.from_db(GraphModel._process_node(node, for_export))
for node in graph.AgentNodes or []
],
links=list(
Expand All @@ -382,23 +391,29 @@ def from_db(graph: AgentGraph, hide_credentials: bool = False):
)

@staticmethod
def _process_node(node: AgentNode, hide_credentials: bool) -> NodeModel:
node_dict = {field: getattr(node, field) for field in node.model_fields}
if hide_credentials and "constantInput" in node_dict:
constant_input = json.loads(
node_dict["constantInput"], target_type=dict[str, Any]
)
constant_input = GraphModel._hide_credentials_in_input(constant_input)
node_dict["constantInput"] = json.dumps(constant_input)
return NodeModel.from_db(AgentNode(**node_dict))
def _process_node(node: AgentNode, for_export: bool) -> AgentNode:
if for_export:
# Remove credentials from node input
if node.constantInput:
constant_input = json.loads(
node.constantInput, target_type=dict[str, Any]
)
constant_input = GraphModel._hide_node_input_credentials(constant_input)
node.constantInput = json.dumps(constant_input)

# Remove webhook info
node.webhookId = None
node.Webhook = None

return node

@staticmethod
def _hide_credentials_in_input(input_data: dict[str, Any]) -> dict[str, Any]:
def _hide_node_input_credentials(input_data: dict[str, Any]) -> dict[str, Any]:
sensitive_keys = ["credentials", "api_key", "password", "token", "secret"]
result = {}
for key, value in input_data.items():
if isinstance(value, dict):
result[key] = GraphModel._hide_credentials_in_input(value)
result[key] = GraphModel._hide_node_input_credentials(value)
elif isinstance(value, str) and any(
sensitive_key in key.lower() for sensitive_key in sensitive_keys
):
Expand Down Expand Up @@ -495,7 +510,7 @@ async def get_graph(
version: int | None = None,
template: bool = False,
user_id: str | None = None,
hide_credentials: bool = False,
for_export: bool = False,
) -> GraphModel | None:
"""
Retrieves a graph from the DB.
Expand All @@ -521,7 +536,7 @@ async def get_graph(
include=AGENT_GRAPH_INCLUDE,
order={"version": "desc"},
)
return GraphModel.from_db(graph, hide_credentials) if graph else None
return GraphModel.from_db(graph, for_export) if graph else None


async def set_graph_active_version(graph_id: str, version: int, user_id: str) -> None:
Expand Down
30 changes: 27 additions & 3 deletions autogpt_platform/backend/backend/data/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

from prisma import Json
from prisma.models import IntegrationWebhook
from pydantic import Field
from pydantic import Field, computed_field

from backend.data.includes import INTEGRATION_WEBHOOK_INCLUDE
from backend.data.queue import AsyncRedisEventBus
from backend.integrations.providers import ProviderName
from backend.integrations.webhooks.utils import webhook_ingress_url

from .db import BaseDbModel

Expand All @@ -31,6 +32,11 @@ class Webhook(BaseDbModel):

attached_nodes: Optional[list["NodeModel"]] = None

@computed_field
@property
def url(self) -> str:
return webhook_ingress_url(self.provider.value, self.id)

@staticmethod
def from_db(webhook: IntegrationWebhook):
from .graph import NodeModel
Expand Down Expand Up @@ -84,16 +90,18 @@ async def get_webhook(webhook_id: str) -> Webhook:
return Webhook.from_db(webhook)


async def get_all_webhooks(credentials_id: str) -> list[Webhook]:
async def get_all_webhooks_by_creds(credentials_id: str) -> list[Webhook]:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
if not credentials_id:
raise ValueError("credentials_id must not be empty")
webhooks = await IntegrationWebhook.prisma().find_many(
where={"credentialsId": credentials_id},
include=INTEGRATION_WEBHOOK_INCLUDE,
)
return [Webhook.from_db(webhook) for webhook in webhooks]


async def find_webhook(
async def find_webhook_by_credentials_and_props(
credentials_id: str, webhook_type: str, resource: str, events: list[str]
) -> Webhook | None:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
Expand All @@ -109,6 +117,22 @@ async def find_webhook(
return Webhook.from_db(webhook) if webhook else None


async def find_webhook_by_graph_and_props(
graph_id: str, provider: str, webhook_type: str, events: list[str]
) -> Webhook | None:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
webhook = await IntegrationWebhook.prisma().find_first(
where={
"provider": provider,
"webhookType": webhook_type,
"events": {"has_every": events},
"AgentNodes": {"some": {"agentGraphId": graph_id}},
},
include=INTEGRATION_WEBHOOK_INCLUDE,
)
return Webhook.from_db(webhook) if webhook else None


async def update_webhook_config(webhook_id: str, updated_config: dict) -> Webhook:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
_updated_webhook = await IntegrationWebhook.prisma().update(
Expand Down
Loading
Loading