authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 15:14:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:14:52-07:00
logaee889b0e043fdc652e48850698d08ebc563d5bc
treee8a308f4e94945ea61d580bc15fcf4ef6b5103f9
parentb070e52d3e6b49c98117249e5b3f0d46edce173a
signature Signed by PGP key B5690EEEBB952194

feat(mobile): create and copy webhook automations (#15087)


6 files changed, 247 insertions(+), 14 deletions(-)

apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+117-7
......@@ -37,6 +37,9 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor";
3737import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3838import { SegmentedControl } from "../../components/SegmentedControl";
3939import { ThemedSwitch } from "../../components/ThemedSwitch";
40import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
41import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
42import { usePreparedConnection } from "../../state/session";
4043import { buildModelOptions } from "../../lib/modelOptions";
4144import { NativeStackScreenOptions } from "../../native/StackHeader";
4245import { useProjects, useEnvironmentServerConfig } from "../../state/entities";
......@@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection";
5659import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
5760import {
5861 editDraft,
62 DEFAULT_WEBHOOK_PROMPT,
5963 scheduledTaskDefaultModel,
6064 scheduleFromDraft,
6165 type ScheduledTaskDraft as Draft,
......@@ -599,7 +603,14 @@ function TaskForm({
599603 environmentUnavailable
600604 )
601605 return;
602 const schedule = scheduleFromDraft(draft.schedule);
606 // Signatures are edited on desktop and web; send the task's current one,
607 // not the copy taken when this form opened, so a newer edit survives.
608 const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id);
609 const schedule = scheduleFromDraft(
610 draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook"
611 ? { ...draft.schedule, signature: liveTask.schedule.signature }
612 : draft.schedule,
613 );
603614 if (
604615 !draft.title.trim() ||
605616 !draft.prompt.trim() ||
......@@ -811,12 +822,20 @@ function TaskForm({
811822 <SegmentedControl
812823 options={[
813824 { value: "fixed_time", label: "At a time" },
814 { value: "interval", label: "Every interval" },
825 { value: "interval", label: "Interval" },
826 { value: "webhook", label: "Webhook" },
815827 ]}
816828 selected={draft.schedule.mode}
817829 onSelect={(mode) => {
818830 setTimePickerOpen(false);
819 setDraft({ ...draft, schedule: { ...draft.schedule, mode } });
831 setDraft({
832 ...draft,
833 prompt:
834 mode === "webhook" && !draft.prompt.trim()
835 ? DEFAULT_WEBHOOK_PROMPT
836 : draft.prompt,
837 schedule: { ...draft.schedule, mode },
838 });
820839 }}
821840 />
822841 </View>
......@@ -890,6 +909,14 @@ function TaskForm({
890909 }}
891910 />
892911 </>
912 ) : draft.schedule.mode === "webhook" ? (
913 <WebhookScheduleDetails
914 environmentId={environmentId}
915 task={
916 tasks.data?.tasks.find((task) => task.id === draft.task?.id) ?? draft.task ?? null
917 }
918 signatureConfigured={draft.schedule.signature !== null}
919 />
893920 ) : (
894921 <>
895922 <FormField
......@@ -945,6 +972,90 @@ function TaskForm({
945972 );
946973}
947974
975function WebhookScheduleDetails({
976 environmentId,
977 task,
978 signatureConfigured,
979}: {
980 readonly environmentId: EnvironmentId;
981 readonly task: ScheduledTask | null;
982 readonly signatureConfigured: boolean;
983}) {
984 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
985 label: "scheduled task rotate webhook token",
986 reportFailure: false,
987 });
988 const preparedConnection = usePreparedConnection(environmentId);
989 const httpBaseUrl =
990 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null;
991 const webhook = task?.schedule.type === "webhook" ? task.webhook : undefined;
992 // Without T3 Connect, the path is resolved on the address this phone uses.
993 const resolved = webhook ? webhookAddress(webhook, httpBaseUrl) : null;
994 return (
995 <View className="gap-2 border-t border-border-subtle px-4 py-3">
996 <Text className="text-sm text-foreground-muted">
997 {
998 "The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees."
999 }
1000 </Text>
1001 {task === null || resolved === null ? (
1002 <Text className="text-sm text-foreground-muted">Save the task to get its webhook URL.</Text>
1003 ) : (
1004 <>
1005 <Pressable
1006 accessibilityRole="button"
1007 accessibilityLabel="Copy webhook URL"
1008 accessibilityHint="Copies the URL to the clipboard"
1009 // A bare path is not something a sender can call, so only full URLs copy.
1010 disabled={!resolved.copyable}
1011 onPress={() => void tryCopyTextWithHaptic(resolved.address)}
1012 className="gap-1 active:opacity-70"
1013 >
1014 <Text className="text-lg text-foreground">
1015 {resolved.copyable ? "Webhook URL" : "Webhook path"}
1016 </Text>
1017 <Text className="text-sm text-foreground-muted" numberOfLines={2} selectable>
1018 {resolved.address}
1019 </Text>
1020 </Pressable>
1021 {resolved.note !== null ? (
1022 <Text className="text-sm text-foreground-muted">{resolved.note}</Text>
1023 ) : null}
1024 <Pressable
1025 accessibilityRole="button"
1026 onPress={() =>
1027 Alert.alert("Rotate URL?", "The current URL stops working immediately.", [
1028 { text: "Cancel", style: "cancel" },
1029 {
1030 text: "Rotate",
1031 style: "destructive",
1032 onPress: () =>
1033 void rotate({ environmentId, input: { id: task.id } }).then((result) => {
1034 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1035 Alert.alert(
1036 "Could not rotate URL",
1037 String(squashAtomCommandFailure(result)),
1038 );
1039 }
1040 }),
1041 },
1042 ])
1043 }
1044 className="min-h-11 justify-center active:opacity-70"
1045 >
1046 <Text className="text-base text-danger-foreground">Rotate URL</Text>
1047 </Pressable>
1048 </>
1049 )}
1050 {signatureConfigured ? (
1051 <Text className="text-sm text-foreground-muted">
1052 Signature check configured on desktop/web.
1053 </Text>
1054 ) : null}
1055 </View>
1056 );
1057}
1058
9481059function EnvironmentTasks({
9491060 environment,
9501061 now,
......@@ -1025,8 +1136,6 @@ function EnvironmentTasks({
10251136 <Pressable
10261137 accessibilityRole="button"
10271138 accessibilityLabel={`Edit ${task.title}`}
1028 // Webhook tasks are not editable here yet; saving would drop their URL.
1029 disabled={task.schedule.type === "webhook"}
10301139 onPress={() => {
10311140 onEdit(task);
10321141 }}
......@@ -1051,9 +1160,10 @@ function EnvironmentTasks({
10511160 </Pressable>
10521161 <ControlPillMenu
10531162 actions={[
1054 ...(task.schedule.type === "webhook" ? [] : [{ id: "edit", title: "Edit" }]),
1163 { id: "edit", title: "Edit" },
10551164 { id: "toggle", title: task.enabled ? "Pause" : "Resume" },
1056 { id: "run", title: "Run now" },
1165 // A webhook task has no request to run without.
1166 ...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]),
10571167 { id: "delete", title: "Delete", attributes: { destructive: true } },
10581168 ]}
10591169 onPressAction={({ nativeEvent }) => {
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+19
......@@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => {
2828 const schedule = { type: "interval" as const, everyMs: 65_000 };
2929 expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule);
3030 });
31
32 it("round-trips a webhook schedule without a signature", () => {
33 const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } });
34 expect(draft.mode).toBe("webhook");
35 expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null });
36 });
37
38 it("keeps a webhook signature on save without sending a secret", () => {
39 const signature = {
40 header: "x-hub-signature-256",
41 encoding: "hex" as const,
42 prefix: "sha256=",
43 };
44 const saved = scheduleFromDraft(
45 scheduleDraftForTask({ schedule: { type: "webhook", signature } }),
46 );
47 expect(saved).toEqual({ type: "webhook", signature });
48 expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false);
49 });
3150});
3251
3352describe("hasScheduledTaskDraftChanges", () => {
apps/mobile/src/features/settings/scheduledTaskDraft.ts+31-7
......@@ -5,6 +5,7 @@ import type {
55 RuntimeMode,
66 ScheduledTask,
77 ScheduledTaskUpsertSchedule,
8 ScheduledTaskWebhookSignature,
89} from "@t3tools/contracts";
910
1011import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
......@@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel(
3738}
3839
3940export type ScheduleDraft = {
40 readonly mode: "fixed_time" | "interval";
41 readonly mode: "fixed_time" | "interval" | "webhook";
4142 readonly timeOfDay: string;
4243 readonly weekdays: ReadonlyArray<number>;
4344 readonly intervalMinutes: string;
45 /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */
46 readonly signature: ScheduledTaskWebhookSignature | null;
4447};
4548
4649export const DEFAULT_SCHEDULE: ScheduleDraft = {
......@@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
4851 timeOfDay: "09:00",
4952 weekdays: [1, 2, 3, 4, 5],
5053 intervalMinutes: "15",
54 signature: null,
5155};
5256
57/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */
58export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}";
59
5360export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {
54 // Webhook tasks cannot be edited here yet; show them as the default schedule.
55 if (task.schedule.type === "webhook") return DEFAULT_SCHEDULE;
56 return task.schedule.type === "fixed_time"
57 ? {
61 switch (task.schedule.type) {
62 case "fixed_time":
63 return {
5864 ...DEFAULT_SCHEDULE,
5965 timeOfDay: task.schedule.timeOfDay,
6066 weekdays: task.schedule.weekdays?.length
6167 ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
6268 : [0, 1, 2, 3, 4, 5, 6],
63 }
64 : {
69 };
70 case "interval":
71 return {
6572 ...DEFAULT_SCHEDULE,
6673 mode: "interval",
6774 intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),
6875 };
76 case "webhook":
77 return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature };
78 }
6979}
7080
7181export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null {
82 if (draft.mode === "webhook") {
83 // No secret is sent, so the server keeps the stored one.
84 return {
85 type: "webhook",
86 signature:
87 draft.signature === null
88 ? null
89 : {
90 header: draft.signature.header,
91 encoding: draft.signature.encoding,
92 prefix: draft.signature.prefix,
93 },
94 };
95 }
7296 if (draft.mode === "interval") {
7397 const minutes = Number(draft.intervalMinutes);
7498 // Undo floating-point noise from displaying existing millisecond intervals as minutes.
packages/client-runtime/package.json+4
......@@ -131,6 +131,10 @@
131131 "types": "./src/textPaste.ts",
132132 "default": "./src/textPaste.ts"
133133 },
134 "./webhook-address": {
135 "types": "./src/webhookAddress.ts",
136 "default": "./src/webhookAddress.ts"
137 },
134138 "./delayed-status": {
135139 "types": "./src/delayedStatus.ts",
136140 "default": "./src/delayedStatus.ts"
packages/client-runtime/src/webhookAddress.test.ts created+36
......@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { webhookAddress } from "./webhookAddress.ts";
4
5const path = "/api/hooks/scheduled-task%3Ahook/token";
6const endpoint = (url: string | null) => ({ path, url, hasSecret: false });
7
8describe("webhookAddress", () => {
9 it("uses the T3 Connect URL when the server has one", () => {
10 expect(webhookAddress(endpoint("https://relay.t3.codes/v1/hooks/k/t/x"), null)).toEqual({
11 address: "https://relay.t3.codes/v1/hooks/k/t/x",
12 copyable: true,
13 note: null,
14 });
15 });
16
17 it("builds a direct URL on the environment's address without T3 Connect", () => {
18 const result = webhookAddress(endpoint(null), "https://mac.tail1234.ts.net/");
19 expect(result.address).toBe(`https://mac.tail1234.ts.net${path}`);
20 expect(result.copyable).toBe(true);
21 expect(result.note).toContain("Tailscale");
22 });
23
24 it("says only this computer can call a loopback address", () => {
25 const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/");
26 expect(result.copyable).toBe(true);
27 expect(result.note).toContain("Only this computer");
28 });
29
30 it("falls back to the path when the address is unknown", () => {
31 expect(webhookAddress(endpoint(null), null)).toMatchObject({
32 address: path,
33 copyable: false,
34 });
35 });
36});
packages/client-runtime/src/webhookAddress.ts created+40
......@@ -0,0 +1,40 @@
1import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts";
2import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";
3
4/**
5 * Where a sender can call a webhook task, as a client shows it. With T3
6 * Connect the server returns a public URL; without it the path is resolved on
7 * the address this client reaches the environment at.
8 */
9export interface WebhookAddress {
10 /** The URL to give a sender, or the bare path when no address is known. */
11 readonly address: string;
12 /** Whether `address` is a full URL a sender can call. */
13 readonly copyable: boolean;
14 /** One line on who can reach `address`; null for a T3 Connect URL. */
15 readonly note: string | null;
16}
17
18export function webhookAddress(
19 endpoint: ScheduledTaskWebhookEndpoint,
20 httpBaseUrl: string | null,
21): WebhookAddress {
22 if (endpoint.url !== null) {
23 return { address: endpoint.url, copyable: true, note: null };
24 }
25 if (httpBaseUrl === null) {
26 return {
27 address: endpoint.path,
28 copyable: false,
29 note: "Link this environment to T3 Connect for a public URL.",
30 };
31 }
32 const url = new URL(endpoint.path, httpBaseUrl);
33 return {
34 address: url.href,
35 copyable: true,
36 note: isLocalLoopbackHost(url.hostname)
37 ? "Only this computer can call this address. Link T3 Connect for a public URL."
38 : "Works wherever this environment's address is reachable, for example over Tailscale or your own proxy. Link T3 Connect for a public URL.",
39 };
40}