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";...@@ -37,6 +37,9 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor";
37import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";37import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
38import { SegmentedControl } from "../../components/SegmentedControl";38import { SegmentedControl } from "../../components/SegmentedControl";
39import { ThemedSwitch } from "../../components/ThemedSwitch";39import { ThemedSwitch } from "../../components/ThemedSwitch";
40import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
41import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
42import { usePreparedConnection } from "../../state/session";
40import { buildModelOptions } from "../../lib/modelOptions";43import { buildModelOptions } from "../../lib/modelOptions";
41import { NativeStackScreenOptions } from "../../native/StackHeader";44import { NativeStackScreenOptions } from "../../native/StackHeader";
42import { useProjects, useEnvironmentServerConfig } from "../../state/entities";45import { useProjects, useEnvironmentServerConfig } from "../../state/entities";
...@@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection";...@@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection";
56import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";59import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
57import {60import {
58 editDraft,61 editDraft,
62 DEFAULT_WEBHOOK_PROMPT,
59 scheduledTaskDefaultModel,63 scheduledTaskDefaultModel,
60 scheduleFromDraft,64 scheduleFromDraft,
61 type ScheduledTaskDraft as Draft,65 type ScheduledTaskDraft as Draft,
...@@ -599,7 +603,14 @@ function TaskForm({...@@ -599,7 +603,14 @@ function TaskForm({
599 environmentUnavailable603 environmentUnavailable
600 )604 )
601 return;605 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 );
603 if (614 if (
604 !draft.title.trim() ||615 !draft.title.trim() ||
605 !draft.prompt.trim() ||616 !draft.prompt.trim() ||
...@@ -811,12 +822,20 @@ function TaskForm({...@@ -811,12 +822,20 @@ function TaskForm({
811 <SegmentedControl822 <SegmentedControl
812 options={[823 options={[
813 { value: "fixed_time", label: "At a time" },824 { value: "fixed_time", label: "At a time" },
814 { value: "interval", label: "Every interval" },825 { value: "interval", label: "Interval" },
826 { value: "webhook", label: "Webhook" },
815 ]}827 ]}
816 selected={draft.schedule.mode}828 selected={draft.schedule.mode}
817 onSelect={(mode) => {829 onSelect={(mode) => {
818 setTimePickerOpen(false);830 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 });
820 }}839 }}
821 />840 />
822 </View>841 </View>
...@@ -890,6 +909,14 @@ function TaskForm({...@@ -890,6 +909,14 @@ function TaskForm({
890 }}909 }}
891 />910 />
892 </>911 </>
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 />
893 ) : (920 ) : (
894 <>921 <>
895 <FormField922 <FormField
...@@ -945,6 +972,90 @@ function TaskForm({...@@ -945,6 +972,90 @@ function TaskForm({
945 );972 );
946}973}
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
948function EnvironmentTasks({1059function EnvironmentTasks({
949 environment,1060 environment,
950 now,1061 now,
...@@ -1025,8 +1136,6 @@ function EnvironmentTasks({...@@ -1025,8 +1136,6 @@ function EnvironmentTasks({
1025 <Pressable1136 <Pressable
1026 accessibilityRole="button"1137 accessibilityRole="button"
1027 accessibilityLabel={`Edit ${task.title}`}1138 accessibilityLabel={`Edit ${task.title}`}
1028 // Webhook tasks are not editable here yet; saving would drop their URL.
1029 disabled={task.schedule.type === "webhook"}
1030 onPress={() => {1139 onPress={() => {
1031 onEdit(task);1140 onEdit(task);
1032 }}1141 }}
...@@ -1051,9 +1160,10 @@ function EnvironmentTasks({...@@ -1051,9 +1160,10 @@ function EnvironmentTasks({
1051 </Pressable>1160 </Pressable>
1052 <ControlPillMenu1161 <ControlPillMenu
1053 actions={[1162 actions={[
1054 ...(task.schedule.type === "webhook" ? [] : [{ id: "edit", title: "Edit" }]),1163 { id: "edit", title: "Edit" },
1055 { id: "toggle", title: task.enabled ? "Pause" : "Resume" },1164 { 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" }]),
1057 { id: "delete", title: "Delete", attributes: { destructive: true } },1167 { id: "delete", title: "Delete", attributes: { destructive: true } },
1058 ]}1168 ]}
1059 onPressAction={({ nativeEvent }) => {1169 onPressAction={({ nativeEvent }) => {
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+19
...@@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => {...@@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => {
28 const schedule = { type: "interval" as const, everyMs: 65_000 };28 const schedule = { type: "interval" as const, everyMs: 65_000 };
29 expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule);29 expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule);
30 });30 });
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 });
31});50});
3251
33describe("hasScheduledTaskDraftChanges", () => {52describe("hasScheduledTaskDraftChanges", () => {
apps/mobile/src/features/settings/scheduledTaskDraft.ts+31-7
...@@ -5,6 +5,7 @@ import type {...@@ -5,6 +5,7 @@ import type {
5 RuntimeMode,5 RuntimeMode,
6 ScheduledTask,6 ScheduledTask,
7 ScheduledTaskUpsertSchedule,7 ScheduledTaskUpsertSchedule,
8 ScheduledTaskWebhookSignature,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
910
10import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";11import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
...@@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel(...@@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel(
37}38}
3839
39export type ScheduleDraft = {40export type ScheduleDraft = {
40 readonly mode: "fixed_time" | "interval";41 readonly mode: "fixed_time" | "interval" | "webhook";
41 readonly timeOfDay: string;42 readonly timeOfDay: string;
42 readonly weekdays: ReadonlyArray<number>;43 readonly weekdays: ReadonlyArray<number>;
43 readonly intervalMinutes: string;44 readonly intervalMinutes: string;
45 /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */
46 readonly signature: ScheduledTaskWebhookSignature | null;
44};47};
4548
46export const DEFAULT_SCHEDULE: ScheduleDraft = {49export const DEFAULT_SCHEDULE: ScheduleDraft = {
...@@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {...@@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
48 timeOfDay: "09:00",51 timeOfDay: "09:00",
49 weekdays: [1, 2, 3, 4, 5],52 weekdays: [1, 2, 3, 4, 5],
50 intervalMinutes: "15",53 intervalMinutes: "15",
54 signature: null,
51};55};
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
53export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {60export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {
54 // Webhook tasks cannot be edited here yet; show them as the default schedule.61 switch (task.schedule.type) {
55 if (task.schedule.type === "webhook") return DEFAULT_SCHEDULE;62 case "fixed_time":
56 return task.schedule.type === "fixed_time"63 return {
57 ? {
58 ...DEFAULT_SCHEDULE,64 ...DEFAULT_SCHEDULE,
59 timeOfDay: task.schedule.timeOfDay,65 timeOfDay: task.schedule.timeOfDay,
60 weekdays: task.schedule.weekdays?.length66 weekdays: task.schedule.weekdays?.length
61 ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)67 ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
62 : [0, 1, 2, 3, 4, 5, 6],68 : [0, 1, 2, 3, 4, 5, 6],
63 }69 };
64 : {70 case "interval":
71 return {
65 ...DEFAULT_SCHEDULE,72 ...DEFAULT_SCHEDULE,
66 mode: "interval",73 mode: "interval",
67 intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),74 intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),
68 };75 };
76 case "webhook":
77 return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature };
78 }
69}79}
7080
71export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null {81export 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 }
72 if (draft.mode === "interval") {96 if (draft.mode === "interval") {
73 const minutes = Number(draft.intervalMinutes);97 const minutes = Number(draft.intervalMinutes);
74 // Undo floating-point noise from displaying existing millisecond intervals as minutes.98 // Undo floating-point noise from displaying existing millisecond intervals as minutes.
packages/client-runtime/package.json+4
...@@ -131,6 +131,10 @@...@@ -131,6 +131,10 @@
131 "types": "./src/textPaste.ts",131 "types": "./src/textPaste.ts",
132 "default": "./src/textPaste.ts"132 "default": "./src/textPaste.ts"
133 },133 },
134 "./webhook-address": {
135 "types": "./src/webhookAddress.ts",
136 "default": "./src/webhookAddress.ts"
137 },
134 "./delayed-status": {138 "./delayed-status": {
135 "types": "./src/delayedStatus.ts",139 "types": "./src/delayedStatus.ts",
136 "default": "./src/delayedStatus.ts"140 "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}