diff --git a/dean/ios-quick-connect/bluebubbles.md b/dean/ios-quick-connect/bluebubbles.md new file mode 100644 index 000000000000..b9bb492fcfa6 --- /dev/null +++ b/dean/ios-quick-connect/bluebubbles.md @@ -0,0 +1,182 @@ +--- +title: "iMessage (BlueBubbles) – iOS Quick Connect" +summary: "How to connect iMessage via a BlueBubbles server to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the iMessage / BlueBubbles quick-connect screen in the iOS app +--- + +# iMessage (BlueBubbles) – iOS Quick Connect + +**Auth method:** Guided form (server URL + password) +**Complexity:** Medium + +iMessage is supported through the [BlueBubbles](https://bluebubbles.app) macOS helper app. The user must have a Mac running the BlueBubbles server accessible from the internet (or via a tunnel). The iOS app connects the user's OpenClaw container to that server using the server's URL and password. + +> **Prerequisite:** The user must already have a Mac running macOS 13+ with the BlueBubbles server installed and its web API enabled. This is an inherently macOS-dependent integration. + +--- + +## User flow + +``` +1. User opens Settings → Channels → iMessage +2. iOS app shows a "Connect BlueBubbles" screen with: + - Text field for "Server URL" (e.g., https://my-mac.example.com:1234) + - SecureField for "Password" + - (Optional) text field for "Webhook path" (default: /bluebubbles-webhook) +3. User taps "Test Connection" → app verifies /health endpoint +4. User taps "Connect" → config is written to the container +5. Container registers its webhook with the BlueBubbles server +6. iOS app shows success with server version info +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct BlueBubblesConnectView: View { + @State private var serverURL = "" + @State private var password = "" + @State private var webhookPath = "/bluebubbles-webhook" + @State private var healthResult: String? = nil + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section(header: Text("BlueBubbles Server")) { + TextField("https://my-mac.example.com:1234", text: $serverURL) + .textContentType(.URL) + .autocorrectionDisabled() + .autocapitalization(.none) + SecureField("Password", text: $password) + .textContentType(.password) + } + Section(header: Text("Webhook Path")) { + TextField("/bluebubbles-webhook", text: $webhookPath) + .autocorrectionDisabled() + .autocapitalization(.none) + } + Section { + Button("Test Connection") { Task { await testConnection() } } + if let health = healthResult { + Text(health).foregroundStyle(.green).font(.footnote) + } + } + Section { + Button("Connect") { Task { await connect() } } + .disabled(serverURL.isEmpty || password.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect iMessage") + } + + private func testConnection() async { + guard let url = URL(string: serverURL.appending("/api/v1/ping")) else { return } + var request = URLRequest(url: url) + request.setValue(password, forHTTPHeaderField: "x-password") + guard let (data, _) = try? await URLSession.shared.data(for: request) else { + healthResult = "✗ Could not reach server" + return + } + let ping = try? JSONDecoder().decode(BlueBubblesPing.self, from: data) + healthResult = ping != nil ? "✓ Connected to BlueBubbles \(ping?.data.name ?? "")" : "✗ Could not reach server" + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "bluebubbles", + config: [ + "enabled": true, + "serverUrl": serverURL, + "password": password, + "webhookPath": webhookPath, + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +### 2. Connection test (client-side) + +The iOS app can directly call the BlueBubbles `/api/v1/ping` endpoint before submitting to the container: + +```swift +func testBlueBubblesServer(url: String, password: String) async throws -> BlueBubblesPing { + var request = URLRequest(url: URL(string: "\(url)/api/v1/ping")!) + request.setValue(password, forHTTPHeaderField: "x-password") + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw ChannelError.serverUnreachable + } + return try JSONDecoder().decode(BlueBubblesPing.self, from: data) +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + bluebubbles: { + enabled: true, + serverUrl: "https://my-mac.example.com:1234", + password: "", + webhookPath: "/bluebubbles-webhook", + dmPolicy: "pairing", + allowFrom: [], + }, + }, +} +``` + +After the config is applied the container will automatically register its webhook URL with the BlueBubbles server. The user should not need to do anything in the BlueBubbles Mac app. + +--- + +## Webhook registration (automatic) + +When the OpenClaw gateway starts with a valid BlueBubbles config it registers a webhook with the server at: + +``` +https:///bluebubbles-webhook?password= +``` + +Since OpenClaw containers run behind a ClusterIP-only service, the gateway must be accessible to the BlueBubbles server. Options: + +- **Cloudflare Tunnel** — Recommended. The container's gateway port is exposed via a dedicated Cloudflare Tunnel, and the tunnel URL is registered as the webhook. +- **ngrok / other tunnels** — Acceptable for development. +- **Direct AKS ingress** — Requires additional AKS ingress configuration. + +The iOS app setup screen can display the tunnel URL for the user's container so they can verify webhook registration in the BlueBubbles app. + +--- + +## BlueBubbles server setup guide (in-app) + +The iOS app should include a brief setup guide for users who have not yet configured BlueBubbles on their Mac: + +1. Download and install [BlueBubbles for macOS](https://bluebubbles.app/install). +2. Open BlueBubbles → Settings → Server → enable **Enable LAN URL**. +3. Set a strong **Server Password**. +4. Expose the server to the internet (port forwarding, Cloudflare Tunnel, or ngrok). +5. Copy the public URL and paste it into the iOS app. + +--- + +## Security notes + +- The BlueBubbles password grants full iMessage read/write access. Treat it like a root credential. +- Only accept HTTPS server URLs; reject plain HTTP to prevent token interception. +- Webhook authentication is always verified by OpenClaw using the `x-password` / `?password=` parameter before processing any webhook payload. +- Recommend users run BlueBubbles on a dedicated Mac account with iMessage signed in only for the bot number. diff --git a/dean/ios-quick-connect/discord.md b/dean/ios-quick-connect/discord.md new file mode 100644 index 000000000000..4980c7847cbe --- /dev/null +++ b/dean/ios-quick-connect/discord.md @@ -0,0 +1,167 @@ +--- +title: "Discord – iOS Quick Connect" +summary: "How to connect a Discord bot to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Discord quick-connect screen in the iOS app +--- + +# Discord – iOS Quick Connect + +**Auth method:** Token paste +**Complexity:** Low + +Discord uses a long-lived **bot token** that the user copies from the Discord Developer Portal and pastes into the iOS app. No OAuth round-trip or QR scan is required. + +--- + +## User flow + +``` +1. User opens Settings → Channels → Discord +2. iOS app shows a "Connect Discord" screen with: + - Link to discord.com/developers/applications (opens in SFSafariViewController) + - Secure text field for "Bot Token" + - Optional: text field for "Guild ID" (to scope the bot to one server) +3. User taps "Connect" → token is validated then saved to the container +4. Success screen shows bot username and connection status +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct DiscordConnectView: View { + @State private var botToken = "" + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section(header: Text("Bot Token")) { + SecureField("xxxxxxxxxxxxxxxxxxx.xxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxx", text: $botToken) + .textContentType(.password) + .autocorrectionDisabled() + } + Section { + Button("Connect") { + Task { await connect() } + } + .disabled(botToken.isEmpty || status == .connecting) + } + if case .error(let msg) = status { + Text(msg).foregroundStyle(.red) + } + } + .navigationTitle("Connect Discord") + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "discord", + config: ["botToken": botToken, "enabled": true] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +### 2. Token validation (optional pre-check) + +Before patching the container config, the iOS app can call the Discord API directly to verify the token and display the bot's username: + +```swift +func validateDiscordToken(_ token: String) async throws -> String { + var request = URLRequest(url: URL(string: "https://discord.com/api/v10/users/@me")!) + request.setValue("Bot \(token)", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw ChannelError.invalidToken + } + let user = try JSONDecoder().decode(DiscordUser.self, from: data) + return user.username +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + discord: { + enabled: true, + botToken: "", + // Optional: scope to a specific guild + guilds: { + "": { allowAllChannels: true } + } + } + } +} +``` + +Sent via the file-api config-patch endpoint: + +```http +PATCH /config/channels +Authorization: Bearer +Content-Type: application/json + +{ + "channel": "discord", + "config": { + "enabled": true, + "botToken": "" + } +} +``` + +--- + +## Prerequisites the user must complete first + +| Step | Where | +|------|-------| +| Create a Discord application | [discord.com/developers/applications](https://discord.com/developers/applications) → **New Application** | +| Add a bot to the application | Application → **Bot** → **Add Bot** | +| Enable Message Content Intent | Bot page → **Privileged Gateway Intents** → enable **Message Content Intent** | +| Copy bot token | Bot page → **Reset Token** → copy | +| Invite the bot to a server | OAuth2 → URL Generator → scopes: `bot`, `applications.commands`; permissions: View Channels, Send Messages, Read Message History | + +The iOS app can deep-link to each step using `SFSafariViewController` or include a mini-guide with screenshots. + +--- + +## Quick auth: "Connect with Discord" OAuth2 (alternative) + +If you want a zero-paste experience, you can implement an OAuth2 flow where the user authorizes your app to create a bot on their behalf. This requires you to host an OAuth2 callback endpoint. + +``` +iOS App → ASWebAuthenticationSession + → https://discord.com/oauth2/authorize + ?client_id= + &scope=bot+applications.commands + &permissions= + &redirect_uri= + → User grants access + → Callback receives guild_id + bot_token (if using bot authorization flow) + → App patches container config +``` + +> This approach requires your OAuth2 redirect handler to exchange the code for a bot token and forward it to the container. It is more complex but provides a smoother user experience for non-technical users. + +--- + +## Security notes + +- Store the bot token only on the container (never in the iOS app's local storage or Keychain after the initial POST). +- The file-api endpoint must validate the Supabase JWT before writing to config. +- Tokens displayed in the settings screen should be masked (show only last 4 characters). diff --git a/dean/ios-quick-connect/feishu.md b/dean/ios-quick-connect/feishu.md new file mode 100644 index 000000000000..c81d2f2b8429 --- /dev/null +++ b/dean/ios-quick-connect/feishu.md @@ -0,0 +1,174 @@ +--- +title: "Feishu / Lark – iOS Quick Connect" +summary: "How to connect a Feishu or Lark bot to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Feishu / Lark quick-connect screen in the iOS app +--- + +# Feishu / Lark – iOS Quick Connect + +**Auth method:** Token paste (App ID + App Secret) +**Complexity:** Low + +Feishu (known as Lark outside China) is a team communication platform by ByteDance. OpenClaw connects to it through a custom Feishu app. The user creates an app on the Feishu developer platform and provides its **App ID** and **App Secret**. No OAuth round-trip is required. + +--- + +## User flow + +``` +1. User opens Settings → Channels → Feishu +2. iOS app shows: + - Link: "Open Feishu Developer Console" → open.feishu.cn (or open.larksuite.com for Lark) + - Text field: App ID + - SecureField: App Secret +3. User taps "Connect" +4. Container config is patched; gateway subscribes to events via WebSocket +5. Success screen shows app name and verification status +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct FeishuConnectView: View { + @State private var appId = "" + @State private var appSecret = "" + @State private var isLark = false // Lark (international) vs Feishu (China) + @State private var status: ConnectionStatus = .idle + + var devConsoleURL: URL { + isLark + ? URL(string: "https://open.larksuite.com/app")! + : URL(string: "https://open.feishu.cn/app")! + } + + var body: some View { + Form { + Section { + Toggle("Use Lark (international)", isOn: $isLark) + Link("Open Developer Console", destination: devConsoleURL) + } header: { + Text("Step 1 – Create a Feishu app") + } footer: { + Text("Go to the developer console → Create App → Custom App. Copy the App ID and App Secret from the Credentials section.") + } + + Section(header: Text("Step 2 – Enter credentials")) { + TextField("cli_xxxxxxxxxxxxxxxx", text: $appId) + .autocorrectionDisabled() + .autocapitalization(.none) + SecureField("App Secret", text: $appSecret) + .textContentType(.password) + } + + Section { + Button("Connect") { Task { await connect() } } + .disabled(appId.isEmpty || appSecret.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect Feishu") + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "feishu", + config: [ + "enabled": true, + "appId": appId, + "appSecret": appSecret, + "platform": isLark ? "lark" : "feishu", + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +### 2. Token validation (optional) + +The Feishu API has a tenant access token endpoint that can be used to validate credentials before writing them to the container: + +```swift +func validateFeishuCredentials(appId: String, appSecret: String, isLark: Bool) async throws { + let baseURL = isLark ? "https://open.larksuite.com" : "https://open.feishu.cn" + let url = URL(string: "\(baseURL)/open-apis/auth/v3/app_access_token/internal")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(["app_id": appId, "app_secret": appSecret]) + + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw ChannelError.invalidToken + } + struct TokenResponse: Decodable { let code: Int; let msg: String } + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + guard result.code == 0 else { + throw ChannelError.custom(result.msg) + } +} +``` + +--- + +## Feishu app setup guide (in-app) + +The iOS app should include a brief guide (shown as a collapsible card or sheet): + +1. Open [Feishu Developer Console](https://open.feishu.cn/app) (or [Lark](https://open.larksuite.com/app)). +2. Click **Create App → Custom App**. +3. Choose **Self-Built App**, give it a name (e.g., "OpenClaw"). +4. Go to **Credentials & Basic Info** → copy **App ID** and **App Secret**. +5. Go to **Event Subscriptions** → choose **Long Connection** (WebSocket). No public URL needed. +6. Add required permissions: `im:message`, `im:message.receive_v1`. +7. Publish the app to your workspace. + +--- + +## Container config applied + +```json5 +{ + channels: { + feishu: { + enabled: true, + appId: "cli_xxxxxxxxxxxxxxxx", + appSecret: "", + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Plugin requirement + +Feishu ships bundled with current OpenClaw releases. No separate plugin installation is required for containers running a recent image. + +If the container image is older, the iOS app can trigger a plugin installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/feishu" } +``` + +--- + +## Security notes + +- App Secrets grant full access to the Feishu bot. Treat them like passwords and never log them. +- Use event subscription mode (Long Connection / WebSocket) rather than webhook mode so no public URL is required, reducing the attack surface. +- Scope the Feishu app's permissions to the minimum required (`im:message` and `im:message.receive_v1`). diff --git a/dean/ios-quick-connect/index.md b/dean/ios-quick-connect/index.md new file mode 100644 index 000000000000..45dd0d7c4bf1 --- /dev/null +++ b/dean/ios-quick-connect/index.md @@ -0,0 +1,81 @@ +--- +title: "iOS Quick Connect" +summary: "Per-integration guide for connecting messaging channels to OpenClaw from the managed iOS app" +read_when: + - Building the iOS app's channel-connection UI + - Adding a new integration to the iOS app + - Understanding the credential-flow between the iOS app and a user's container +--- + +# iOS Quick Connect + +This section covers how to implement a **quick connect** for each messaging integration when users run OpenClaw through the managed iOS app. Each guide describes the credential model, the recommended in-app UX, and how those credentials are applied to the user's dedicated OpenClaw container. + +## Architecture overview + +``` +iOS App (SwiftUI) + │ + │ HTTPS + Supabase JWT + ▼ +files.spark.ooo (file-api pod) + │ + │ Azure Files mount (shared with container) + ▼ +~/.openclaw/config.json inside user's AKS pod + │ + ▼ +openclaw gateway (picks up config change and reconnects) +``` + +The iOS app writes channel credentials through the file API. The container's OpenClaw process watches for config changes and reconnects automatically. + +### Config-patch endpoint + +All quick-connect flows described here assume a `/config/channels` PATCH endpoint exposed by the file-api pod: + +```http +PATCH /config/channels +Authorization: Bearer +Content-Type: application/json + +{ + "channel": "discord", + "config": { "botToken": "...", "enabled": true } +} +``` + +The file-api pod merges this payload into `~/.openclaw/config.json` under `channels.` and sends a `SIGHUP` (or equivalent reload signal) to the running gateway process. + +> If the config-patch endpoint does not yet exist, you can achieve the same result by writing the full config file via `PUT /files/config.json` and restarting the container. + +## Auth method glossary + +| Method | What the user does in the iOS app | +|--------|-----------------------------------| +| **Token paste** | User copies a token from a web UI and pastes it into a secure text field. | +| **OAuth in-app** | iOS app opens a `ASWebAuthenticationSession` to the service's OAuth URL; the token is captured automatically on redirect. | +| **QR scan** | iOS app displays a QR code fetched from the container; user scans it with the target messaging app on another device. | +| **Guided form** | Multi-step form collects several credentials (server URL, bot ID, secret, etc.). | +| **Phone registration** | User provides a phone number; the container sends/receives an OTP via the service's API. | + +## Integration index + +| Integration | Auth method | Complexity | +|-------------|-------------|------------| +| [Discord](discord) | Token paste | Low | +| [Telegram](telegram) | Token paste | Low | +| [Slack](slack) | OAuth in-app or token paste | Medium | +| [WhatsApp](whatsapp) | QR scan | Medium | +| [Signal](signal) | Phone registration or QR link | High | +| [iMessage (BlueBubbles)](bluebubbles) | Guided form (server URL + password) | Medium | +| [Microsoft Teams](msteams) | Guided form (Azure credentials) | High | +| [Matrix](matrix) | Token paste or OAuth/SSO | Medium | +| [Feishu / Lark](feishu) | Token paste | Low | +| [Nostr](nostr) | Key paste or in-app keygen | Low | +| [Twitch](twitch) | OAuth in-app | Low | +| [Zalo](zalo) | Token paste | Low | +| [Mattermost](mattermost) | Token paste + server URL | Low | +| [IRC](irc) | Guided form (server + nick) | Low | +| [LINE](line) | Token paste | Low | +| [Tlon / Urbit](tlon) | Guided form | Medium | diff --git a/dean/ios-quick-connect/irc.md b/dean/ios-quick-connect/irc.md new file mode 100644 index 000000000000..ed42bbb8c8bc --- /dev/null +++ b/dean/ios-quick-connect/irc.md @@ -0,0 +1,147 @@ +--- +title: "IRC – iOS Quick Connect" +summary: "How to connect an IRC server to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the IRC quick-connect screen in the iOS app +--- + +# IRC – iOS Quick Connect + +**Auth method:** Guided form (server + nick + optional password) +**Complexity:** Low + +IRC is a classic text-based chat protocol. OpenClaw connects as an IRC client. The user provides a server address, port, nickname, and optional NickServ/SASL credentials. No external developer accounts or OAuth flows are needed. + +--- + +## User flow + +``` +1. User opens Settings → Channels → IRC +2. iOS app shows a form: + - Text field: "Server" (e.g., irc.libera.chat) + - Number field: "Port" (default: 6697) + - Toggle: "Use TLS" (default: on) + - Text field: "Nickname" + - SecureField: "NickServ Password" (optional) + - Text field: "Channel(s) to join" (comma-separated, e.g., #openclaw,#help) +3. User taps "Connect" +4. Container config is patched; gateway connects to IRC +5. Success screen shows connection status +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct IRCConnectView: View { + @State private var server = "irc.libera.chat" + @State private var port = "6697" + @State private var useTLS = true + @State private var nickname = "" + @State private var password = "" + @State private var channels = "#openclaw" + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section(header: Text("Server")) { + TextField("irc.libera.chat", text: $server) + .autocorrectionDisabled() + .autocapitalization(.none) + .keyboardType(.URL) + TextField("Port", text: $port) + .keyboardType(.numberPad) + Toggle("Use TLS", isOn: $useTLS) + } + + Section(header: Text("Identity")) { + TextField("Nickname", text: $nickname) + .autocorrectionDisabled() + .autocapitalization(.none) + SecureField("NickServ Password (optional)", text: $password) + .textContentType(.password) + } + + Section(header: Text("Channels to join (comma-separated)")) { + TextField("#openclaw,#help", text: $channels) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + Section { + Button("Connect") { Task { await connect() } } + .disabled(server.isEmpty || nickname.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect IRC") + } + + private func connect() async { + status = .connecting + let channelList = channels.split(separator: ",").map(String.init).map { $0.trimmingCharacters(in: .whitespaces) } + var config: [String: Any] = [ + "enabled": true, + "server": server, + "port": Int(port) ?? 6697, + "tls": useTLS, + "nickname": nickname, + "channels": channelList, + ] + if !password.isEmpty { config["nickservPassword"] = password } + do { + try await ChannelConfigService.shared.patch(channel: "irc", config: config) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + irc: { + enabled: true, + server: "irc.libera.chat", + port: 6697, + tls: true, + nickname: "openclaw-bot", + nickservPassword: "", // optional + channels: ["#openclaw", "#help"], + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Popular IRC networks (quick reference) + +The iOS app can show a picker of popular IRC networks to simplify server entry: + +| Network | Server | TLS Port | +|---------|--------|----------| +| Libera.Chat | `irc.libera.chat` | 6697 | +| OFTC | `irc.oftc.net` | 6697 | +| freenode | `irc.freenode.net` | 7000 | +| EFnet | `irc.efnet.org` | 6697 | +| IRCnet | `open.ircnet.net` | 6697 | + +--- + +## Security notes + +- Always use TLS (port 6697 or 7000 depending on the network) to protect credentials in transit. +- NickServ passwords are stored on the container and used only during registration. Do not use the same password as other accounts. +- IRC is a low-security protocol; do not use it to exchange sensitive information. +- Consider using SASL authentication instead of NickServ if the IRC network supports it. diff --git a/dean/ios-quick-connect/line.md b/dean/ios-quick-connect/line.md new file mode 100644 index 000000000000..19c93cd19d23 --- /dev/null +++ b/dean/ios-quick-connect/line.md @@ -0,0 +1,175 @@ +--- +title: "LINE – iOS Quick Connect" +summary: "How to connect a LINE Messaging API channel to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the LINE quick-connect screen in the iOS app +--- + +# LINE – iOS Quick Connect + +**Auth method:** Token paste (channel access token + channel secret) +**Complexity:** Low + +LINE uses the Messaging API with a **channel access token** and **channel secret**. The user creates a Messaging API channel on the LINE Developers Console and pastes the two credentials into the iOS app. A publicly accessible webhook URL is also required. + +--- + +## User flow + +``` +1. User opens Settings → Channels → LINE +2. iOS app shows: + - Link: "Open LINE Developers Console" (SFSafariViewController) + - SecureField: "Channel Access Token" + - SecureField: "Channel Secret" + - Display-only: Container's public webhook URL +3. User creates a Messaging API channel, copies credentials, enables webhook +4. Pastes credentials into the iOS app and taps "Connect" +5. Container config is patched; gateway starts listening for LINE events +6. App shows success with bot name +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct LineConnectView: View { + @State private var accessToken = "" + @State private var channelSecret = "" + @State private var status: ConnectionStatus = .idle + + var webhookURL: String { + (ContainerService.shared.containerInfo?.webhookBaseURL ?? "https://…") + "/line/webhook" + } + + var body: some View { + Form { + Section { + Link("Open LINE Developers Console", + destination: URL(string: "https://developers.line.biz/console/")!) + } header: { + Text("Step 1 – Create a Messaging API channel") + } footer: { + Text("Create a Provider → New Channel → Messaging API. Copy the Channel Access Token and Channel Secret.") + } + + Section(header: Text("Channel Access Token")) { + SecureField("Long-lived access token", text: $accessToken) + .textContentType(.password) + .autocorrectionDisabled() + } + + Section(header: Text("Channel Secret")) { + SecureField("32-char hex secret", text: $channelSecret) + .textContentType(.password) + .autocorrectionDisabled() + } + + Section { + Text("Webhook URL (paste into LINE Console):") + .font(.footnote) + .foregroundStyle(.secondary) + Text(webhookURL) + .font(.footnote.monospaced()) + Button("Copy Webhook URL") { UIPasteboard.general.string = webhookURL } + } header: { + Text("Step 2 – Configure webhook in LINE Console") + } footer: { + Text("In the LINE Console, go to Messaging API → Webhook settings, paste the URL above, and enable 'Use webhook'.") + } + + Section { + Button("Connect") { Task { await connect() } } + .disabled(accessToken.isEmpty || channelSecret.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect LINE") + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "line", + config: [ + "enabled": true, + "channelAccessToken": accessToken, + "channelSecret": channelSecret, + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## LINE Developers Console setup guide (in-app) + +1. Go to [developers.line.biz/console](https://developers.line.biz/console). +2. Create or select a **Provider**. +3. Click **Create a new channel** → choose **Messaging API**. +4. Fill in the required fields and create the channel. +5. Go to **Messaging API** tab: + - Under **Channel access token**, click **Issue** to generate a long-lived token. + - Copy the **Channel secret** from the **Basic settings** tab. +6. Under **Webhook settings**: + - Set Webhook URL to the container's webhook URL shown in the iOS app. + - Enable **Use webhook**. + - Click **Verify** to confirm the endpoint is reachable. +7. Disable the **Auto-reply messages** and **Greeting messages** in the LINE Official Account Manager to prevent double responses. + +--- + +## Webhook exposure + +LINE requires a publicly accessible HTTPS endpoint. Each user's container must expose its `/line/webhook` endpoint via: + +- **Cloudflare Tunnel** (recommended) — The container's gateway port is exposed via Cloudflare Tunnel. +- **AKS Ingress** — Per-user subdomain. + +--- + +## Container config applied + +```json5 +{ + channels: { + line: { + enabled: true, + channelAccessToken: "", + channelSecret: "", + dmPolicy: "pairing", + webhookPath: "/line/webhook", + }, + }, +} +``` + +--- + +## Plugin requirement + +LINE ships as a plugin (`@openclaw/line`) and must be installed in the container. The iOS app should check plugin status and offer installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/line" } +``` + +--- + +## Security notes + +- LINE uses HMAC-SHA256 signature verification on webhook payloads. OpenClaw verifies every incoming webhook using the channel secret. +- Store the channel access token and channel secret only on the container side. +- LINE access tokens can be revoked from the LINE Developers Console if the integration needs to be disabled. diff --git a/dean/ios-quick-connect/matrix.md b/dean/ios-quick-connect/matrix.md new file mode 100644 index 000000000000..e515a826bfe6 --- /dev/null +++ b/dean/ios-quick-connect/matrix.md @@ -0,0 +1,211 @@ +--- +title: "Matrix – iOS Quick Connect" +summary: "How to connect a Matrix account to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Matrix quick-connect screen in the iOS app +--- + +# Matrix – iOS Quick Connect + +**Auth method:** Token paste or SSO via homeserver +**Complexity:** Medium + +Matrix is an open, federated protocol. Users need a Matrix account on any homeserver and an **access token** for that account. The iOS app can collect the token via: + +1. **Token paste** — User generates an access token from their homeserver's web client or via the login API and pastes it. +2. **Matrix SSO** — iOS app opens the homeserver's SSO login page in `ASWebAuthenticationSession` and captures the access token on success. + +--- + +## Option A: Token paste + +### User flow + +``` +1. User opens Settings → Channels → Matrix +2. iOS app shows: + - Text field for "Homeserver URL" (e.g., https://matrix.org) + - SecureField for "Access Token" +3. User generates their access token: + - Element/web client: Settings → Help & About → Access Token + - Or via curl (see below) +4. User pastes token and taps "Connect" +5. App verifies the token against the homeserver's /whoami endpoint +6. Container config is patched and gateway reconnects +``` + +### Generating a token (in-app guide) + +The iOS app can display a one-tap guide with a `curl` command the user can run in Terminal, or a link to their homeserver's web UI: + +``` +curl --request POST \ + --url https:///_matrix/client/v3/login \ + --header 'Content-Type: application/json' \ + --data '{ + "type": "m.login.password", + "identifier": { "type": "m.id.user", "user": "@bot:example.org" }, + "password": "your-password" + }' +``` + +### UI (SwiftUI sketch) + +```swift +struct MatrixTokenPasteView: View { + @State private var homeserverURL = "https://matrix.org" + @State private var accessToken = "" + @State private var status: ConnectionStatus = .idle + @State private var verifiedUserId: String? = nil + + var body: some View { + Form { + Section(header: Text("Homeserver URL")) { + TextField("https://matrix.org", text: $homeserverURL) + .textContentType(.URL) + .autocorrectionDisabled() + .autocapitalization(.none) + } + Section(header: Text("Access Token")) { + SecureField("syt_...", text: $accessToken) + .textContentType(.password) + .autocorrectionDisabled() + if let userId = verifiedUserId { + Label(userId, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.footnote) + } + } + Section { + Button("Verify Token") { Task { await verify() } } + Button("Connect") { Task { await connect() } } + .disabled(accessToken.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect Matrix") + } + + private func verify() async { + guard let url = URL(string: homeserverURL + "/_matrix/client/v3/account/whoami") else { return } + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + guard let (data, _) = try? await URLSession.shared.data(for: request) else { + verifiedUserId = nil + return + } + struct WhoAmI: Decodable { let user_id: String } + if let me = try? JSONDecoder().decode(WhoAmI.self, from: data) { + verifiedUserId = me.user_id + } + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "matrix", + config: [ + "enabled": true, + "homeserverUrl": homeserverURL, + "accessToken": accessToken, + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Option B: Matrix SSO (OAuth-like) + +Most homeservers support SSO login. The iOS app can use `ASWebAuthenticationSession` to log the bot account in and capture its access token. + +```swift +func signInViaMatrixSSO(homeserver: String) async throws -> String { + let ssoURL = "\(homeserver)/_matrix/client/v3/login/sso/redirect" + var components = URLComponents(string: ssoURL)! + components.queryItems = [ + .init(name: "redirectUrl", value: "openclaw://matrix/callback"), + ] + + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: "openclaw" + ) { callbackURL, error in + guard let url = callbackURL, + let loginToken = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "loginToken" })?.value else { + continuation.resume(throwing: ChannelError.oauthFailed) + return + } + // Exchange loginToken for access token via /_matrix/client/v3/login + Task { + do { + let token = try await MatrixService.exchangeLoginToken( + homeserver: homeserver, + loginToken: loginToken + ) + continuation.resume(returning: token) + } catch { + continuation.resume(throwing: error) + } + } + } + session.prefersEphemeralWebBrowserSession = true + session.start() + } +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + matrix: { + enabled: true, + homeserverUrl: "https://matrix.org", + accessToken: "", + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Plugin requirement + +Matrix ships as a plugin (`@openclaw/matrix`) that must be installed in the container before the channel can be enabled. The iOS app setup screen should check whether the plugin is installed and offer to install it: + +```http +GET /gateway/plugins +→ [{ "name": "@openclaw/matrix", "installed": false }] + +POST /gateway/plugins/install +{ "package": "@openclaw/matrix" } +``` + +If the plugin is not yet installed, show an "Install Matrix plugin" step before the credential form. + +--- + +## E2EE support + +Matrix End-to-End Encryption (E2EE) requires additional crypto key storage. The container handles key verification automatically when E2EE is enabled in config. The iOS app does not need to manage crypto keys directly. + +--- + +## Security notes + +- Matrix access tokens have no built-in expiry (they stay valid until revoked). Treat them like passwords. +- Use a dedicated Matrix account for the bot; do not use a personal account. +- If the homeserver supports device verification, verify the bot's device in your Matrix client to enable E2EE. +- Revoke the access token from the homeserver's web UI if the integration is disconnected. diff --git a/dean/ios-quick-connect/mattermost.md b/dean/ios-quick-connect/mattermost.md new file mode 100644 index 000000000000..d51bb4a1b2fc --- /dev/null +++ b/dean/ios-quick-connect/mattermost.md @@ -0,0 +1,162 @@ +--- +title: "Mattermost – iOS Quick Connect" +summary: "How to connect a Mattermost workspace to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Mattermost quick-connect screen in the iOS app +--- + +# Mattermost – iOS Quick Connect + +**Auth method:** Token paste + server URL +**Complexity:** Low + +Mattermost is a self-hosted team messaging platform. OpenClaw connects to it using a **bot account access token**. The user provides the Mattermost server URL and the bot token, and the container connects via the Mattermost WebSocket API. + +--- + +## User flow + +``` +1. User opens Settings → Channels → Mattermost +2. iOS app shows: + - Text field: "Mattermost Server URL" (e.g., https://chat.example.com) + - SecureField: "Bot Access Token" + - (Optional) Text field: "Team Name" (to scope the bot to a specific team) +3. User creates a bot account in Mattermost and copies its access token +4. Pastes credentials and taps "Connect" +5. App verifies the token against the Mattermost API +6. Container config is patched; gateway connects via WebSocket +7. Success screen shows the bot's username and server URL +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct MattermostConnectView: View { + @State private var serverURL = "" + @State private var accessToken = "" + @State private var teamName = "" + @State private var status: ConnectionStatus = .idle + @State private var verifiedUsername: String? = nil + + var body: some View { + Form { + Section(header: Text("Mattermost Server URL")) { + TextField("https://chat.example.com", text: $serverURL) + .textContentType(.URL) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + Section(header: Text("Bot Access Token")) { + SecureField("xxxxxxxxxxxxxxxxxxxxxxxxxx", text: $accessToken) + .textContentType(.password) + .autocorrectionDisabled() + if let username = verifiedUsername { + Label("@\(username)", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.footnote) + } + } + + Section(header: Text("Team (optional)")) { + TextField("my-team", text: $teamName) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + Section { + Button("Verify Token") { Task { await verify() } } + Button("Connect") { Task { await connect() } } + .disabled(serverURL.isEmpty || accessToken.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect Mattermost") + } + + private func verify() async { + guard let url = URL(string: serverURL.appending("/api/v4/users/me")) else { return } + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + guard let (data, _) = try? await URLSession.shared.data(for: request) else { + verifiedUsername = nil + return + } + struct MmUser: Decodable { let username: String } + verifiedUsername = try? JSONDecoder().decode(MmUser.self, from: data).username + } + + private func connect() async { + status = .connecting + var config: [String: Any] = [ + "enabled": true, + "serverUrl": serverURL, + "accessToken": accessToken, + "dmPolicy": "pairing", + ] + if !teamName.isEmpty { config["teamName"] = teamName } + do { + try await ChannelConfigService.shared.patch(channel: "mattermost", config: config) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +### 2. Bot account creation guide (in-app) + +The iOS app should include a brief guide for users who have not yet created a bot: + +1. Log into your Mattermost server as an admin. +2. Go to **Main Menu → Integrations → Bot Accounts** → **Add Bot Account**. +3. Set a username (e.g., `openclaw-bot`), role, and description. +4. Click **Create Bot Account** and copy the access token. +5. If you are not a server admin, ask your admin to create a bot account for you or generate a Personal Access Token (if enabled): **Account Settings → Security → Personal Access Tokens**. + +--- + +## Container config applied + +```json5 +{ + channels: { + mattermost: { + enabled: true, + serverUrl: "https://chat.example.com", + accessToken: "", + teamName: "my-team", // optional + dmPolicy: "pairing", + allowFrom: [], + }, + }, +} +``` + +--- + +## Plugin requirement + +Mattermost ships as a plugin (`@openclaw/mattermost`) and must be installed in the container. The iOS app should check plugin status and offer installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/mattermost" } +``` + +--- + +## Security notes + +- Mattermost bot access tokens are equivalent to bot account credentials. Scope them to a bot account (not an admin user) to limit the blast radius if a token is leaked. +- Personal Access Tokens from admin users should be avoided; use a dedicated bot account instead. +- Store the token only on the container side. +- Verify that the Mattermost server's SSL certificate is valid before connecting; reject self-signed certificates in production or require the user to explicitly trust them. diff --git a/dean/ios-quick-connect/msteams.md b/dean/ios-quick-connect/msteams.md new file mode 100644 index 000000000000..27f3118ba106 --- /dev/null +++ b/dean/ios-quick-connect/msteams.md @@ -0,0 +1,200 @@ +--- +title: "Microsoft Teams – iOS Quick Connect" +summary: "How to connect a Microsoft Teams bot to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the MS Teams quick-connect screen in the iOS app +--- + +# Microsoft Teams – iOS Quick Connect + +**Auth method:** Guided form (Azure credentials) or OAuth "Sign in with Microsoft" +**Complexity:** High + +Microsoft Teams requires an **Azure Bot registration** before OpenClaw can connect. This involves creating an Azure App Registration and a Bot Service resource. Because this is a multi-step process, the iOS app should guide the user through it with a wizard-style UI. + +Two approaches are available: + +1. **Guided credential form** — User creates an Azure Bot manually and pastes the App ID, App Password, and Tenant ID. +2. **"Sign in with Microsoft" OAuth** — App handles registration on behalf of the user via Microsoft's OAuth2 flow. This is technically complex and requires a dedicated Azure App registration with delegated permissions. + +--- + +## Option A: Guided credential form (recommended for V1) + +### User flow + +``` +1. User opens Settings → Channels → Microsoft Teams +2. iOS app shows a setup wizard: + Step 1: "Create an Azure Bot" + - Button: "Open Azure Portal" (SFSafariViewController → portal.azure.com) + - Checklist of steps to complete in the portal + Step 2: Enter credentials + - Text field: App ID (Application / Client ID) + - SecureField: App Password (Client Secret) + - Text field: Tenant ID (Directory / Tenant ID) + Step 3: Confirm webhook + - Display: the container's public webhook URL + - Instruction: "Set this as your bot's messaging endpoint in Azure" +3. User taps "Connect" → credentials are validated then saved +4. iOS app shows success screen with bot App ID +``` + +### Credential collection UI (SwiftUI sketch) + +```swift +struct TeamsConnectView: View { + @State private var appId = "" + @State private var appPassword = "" + @State private var tenantId = "" + @State private var step = 1 + @State private var status: ConnectionStatus = .idle + + var webhookURL: String { + // Each container has a unique public endpoint via Cloudflare Tunnel + ContainerService.shared.containerInfo?.webhookBaseURL.appending("/api/messages") ?? "Loading…" + } + + var body: some View { + Form { + if step == 1 { + // Step 1: instruction card + Section { + Link("Open Azure Portal", destination: URL(string: "https://portal.azure.com/#create/Microsoft.AzureBot")!) + SetupChecklistView(steps: [ + "Create an Azure Bot resource", + "Under Configuration, set Messaging endpoint to your webhook URL (shown in Step 3)", + "Under Manage → Certificates & secrets, create a new client secret", + "Copy the App ID, client secret, and tenant ID", + ]) + Button("Next") { step = 2 } + } + } else if step == 2 { + // Step 2: credential fields + Section(header: Text("App ID")) { + TextField("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", text: $appId) + .autocorrectionDisabled().autocapitalization(.none) + } + Section(header: Text("App Password (Client Secret)")) { + SecureField("", text: $appPassword).textContentType(.password) + } + Section(header: Text("Tenant ID")) { + TextField("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", text: $tenantId) + .autocorrectionDisabled().autocapitalization(.none) + } + Button("Next") { step = 3 } + } else { + // Step 3: webhook URL + Section(header: Text("Messaging Endpoint")) { + Text(webhookURL) + .font(.footnote.monospaced()) + Button("Copy") { UIPasteboard.general.string = webhookURL } + } + Text("Paste this URL into your Azure Bot's Messaging Endpoint field, then tap Connect.") + .font(.footnote).foregroundStyle(.secondary) + Button("Connect") { Task { await connect() } } + .disabled(appId.isEmpty || appPassword.isEmpty) + } + } + .navigationTitle("Connect Microsoft Teams") + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "msteams", + config: [ + "enabled": true, + "appId": appId, + "appPassword": appPassword, + "tenantId": tenantId, + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Option B: Sign in with Microsoft OAuth (advanced) + +The iOS app can use `ASWebAuthenticationSession` to acquire a delegated Microsoft token, then use the Microsoft Graph API to create an Azure Bot on behalf of the user. This is the smoothest UX but requires: + +- An Azure App Registration with `AzureBot.ReadWrite.All` and `Application.ReadWrite.All` permissions. +- A backend service to hold the client secret and exchange the authorization code. + +```swift +private func signInWithMicrosoft() async { + let clientId = AppConfig.azureClientId + let tenant = "common" + let redirectURI = "https://files.spark.ooo/oauth/msteams/callback" + let scopes = "https://management.azure.com/.default offline_access" + + var components = URLComponents(string: "https://login.microsoftonline.com/\(tenant)/oauth2/v2.0/authorize")! + components.queryItems = [ + .init(name: "client_id", value: clientId), + .init(name: "response_type", value: "code"), + .init(name: "redirect_uri", value: redirectURI), + .init(name: "scope", value: scopes), + .init(name: "state", value: generateStateToken()), + ] + + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: "openclaw" + ) { callbackURL, _ in + guard let url = callbackURL, + let code = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value else { return } + Task { await self.exchangeCode(code) } + } + session.prefersEphemeralWebBrowserSession = true + session.start() +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + msteams: { + enabled: true, + appId: "", + appPassword: "", + tenantId: "", + webhook: { + port: 3978, + path: "/api/messages", + }, + }, + }, +} +``` + +--- + +## Webhook exposure + +Teams bots require a publicly accessible HTTPS endpoint. Each user's container must expose its `/api/messages` endpoint via: + +- **Cloudflare Tunnel** (recommended) — Tunnel URL shown in the iOS app setup wizard. +- **AKS Ingress** — Per-user subdomain (e.g., `.gateway.openclaw.ai`). + +The iOS app should display the container's webhook URL in the setup wizard so the user can paste it into the Azure Bot configuration. + +--- + +## Security notes + +- Azure App Passwords (client secrets) are equivalent to admin credentials; never log or display them. +- For multi-tenant bots, validate the `tenantId` in every inbound message to prevent cross-tenant message injection. +- Restrict the Azure Bot's allowed tenants to the user's specific tenant when possible. +- Rotate client secrets on a schedule; the iOS app settings screen should surface a "Rotate secret" action. diff --git a/dean/ios-quick-connect/nostr.md b/dean/ios-quick-connect/nostr.md new file mode 100644 index 000000000000..68d1d2bd759a --- /dev/null +++ b/dean/ios-quick-connect/nostr.md @@ -0,0 +1,211 @@ +--- +title: "Nostr – iOS Quick Connect" +summary: "How to connect a Nostr identity to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Nostr quick-connect screen in the iOS app +--- + +# Nostr – iOS Quick Connect + +**Auth method:** Key paste or in-app key generation +**Complexity:** Low + +Nostr is a decentralized, open protocol for censorship-resistant messaging. OpenClaw connects to Nostr via NIP-04 encrypted DMs using a secp256k1 keypair. The iOS app can either: + +1. **Generate a new keypair** in-app (no setup required by the user). +2. **Import an existing private key** (hex or `nsec` bech32 format). + +--- + +## User flow + +### New key (zero-config path) + +``` +1. User opens Settings → Channels → Nostr +2. iOS app shows two options: "Generate new key" and "Import existing key" +3. User taps "Generate new key" +4. iOS app generates a random 32-byte key and displays: + - The npub (public key, bech32) — user can share this as their bot's address + - The nsec (private key) — masked, with a "Copy" and "Save to Notes" button +5. App sends the private key to the container +6. Container config is patched with optional relay URLs +7. Success screen shows the bot's npub +``` + +### Existing key + +``` +1. User selects "Import existing key" +2. Paste field for nsec (bech32) or hex private key +3. App derives and displays the matching npub for confirmation +4. Taps "Connect" +``` + +--- + +## iOS implementation + +### 1. Key generation + +```swift +import CryptoKit + +struct NostrKeyPair { + let privateKeyHex: String + let publicKeyHex: String + + static func generate() -> NostrKeyPair { + // Generate a random 32-byte secret + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, 32, &bytes) + let privateKeyHex = bytes.map { String(format: "%02x", $0) }.joined() + // Derive the secp256k1 public key. + // Use a library such as https://github.com/GigaBitcoin/secp256k1.swift + // or https://github.com/nicktindall/swift-nostr for this step. + let publicKeyHex = Secp256k1.derivePublicKey(from: privateKeyHex) + return NostrKeyPair(privateKeyHex: privateKeyHex, publicKeyHex: publicKeyHex) + } +} +``` + +### 2. UI (SwiftUI sketch) + +```swift +struct NostrConnectView: View { + @State private var mode: Mode = .choose + @State private var privateKey = "" // hex or nsec + @State private var derivedNpub = "" + @State private var relayURLs = "wss://relay.damus.io,wss://relay.primal.net" + @State private var status: ConnectionStatus = .idle + + enum Mode { case choose, generate, importKey } + + var body: some View { + Form { + if mode == .choose { + Section { + Button("Generate a new key") { + let kp = NostrKeyPair.generate() + privateKey = kp.privateKeyHex + // bech32Encode: use a Swift bech32 library (e.g. https://github.com/0xfair/bech32-swift) + // to encode the hex pubkey with hrp "npub". + derivedNpub = bech32Encode(hrp: "npub", data: kp.publicKeyHex) + mode = .generate + } + Button("Import existing key") { mode = .importKey } + } + } else { + if mode == .generate { + Section(header: Text("Your bot's public key (npub) — share this")) { + Text(derivedNpub) + .font(.footnote.monospaced()) + Button("Copy npub") { UIPasteboard.general.string = derivedNpub } + } + Section(header: Text("Private key — keep secret")) { + Text(String(repeating: "•", count: 32)) + Button("Copy nsec (private key)") { + UIPasteboard.general.string = privateKey + } + .foregroundStyle(.orange) + } + } else { + Section(header: Text("Private Key (nsec or hex)")) { + SecureField("nsec1... or 64-char hex", text: $privateKey) + .textContentType(.password) + .autocorrectionDisabled() + .onChange(of: privateKey) { _ in + // deriveNpubFromKey: decode the nsec bech32 to hex, then derive + // the secp256k1 public key and bech32-encode it as npub. + derivedNpub = deriveNpubFromKey(privateKey) + } + if !derivedNpub.isEmpty { + Text("npub: \(derivedNpub)").font(.footnote).foregroundStyle(.secondary) + } + } + } + + Section(header: Text("Relay URLs (comma-separated)")) { + TextField("wss://relay.damus.io", text: $relayURLs) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + Section { + Button("Connect") { Task { await connect() } } + .disabled(privateKey.isEmpty || status == .connecting) + } + } + } + .navigationTitle("Connect Nostr") + } + + private func connect() async { + status = .connecting + let relays = relayURLs.split(separator: ",").map(String.init) + do { + try await ChannelConfigService.shared.patch( + channel: "nostr", + config: [ + "enabled": true, + "privateKey": privateKey, + "relayUrls": relays, + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + nostr: { + enabled: true, + privateKey: "<32-byte-hex-or-nsec>", + relayUrls: [ + "wss://relay.damus.io", + "wss://relay.primal.net", + ], + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Plugin requirement + +Nostr ships as a plugin (`@openclaw/nostr`) and must be installed in the container before enabling the channel. The iOS app should check plugin status and offer installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/nostr" } +``` + +--- + +## Key management in the iOS app + +- After the initial setup, the iOS app should **not** store the private key locally. The key lives only on the container. +- The settings screen can display the npub (public key) fetched from the container for reference. +- Provide a "Rotate key" action that generates a new keypair and updates the container config. Warn the user that rotating the key changes their bot's Nostr identity. + +--- + +## Security notes + +- A Nostr private key is the user's entire identity on the protocol; it cannot be revoked or changed without creating a new identity. +- The key should be transmitted to the container only over HTTPS and stored only in the container's isolated Azure Files volume. +- For the generated-key flow, display a clear warning that losing the key means losing the identity permanently. diff --git a/dean/ios-quick-connect/signal.md b/dean/ios-quick-connect/signal.md new file mode 100644 index 000000000000..43169d5dcac2 --- /dev/null +++ b/dean/ios-quick-connect/signal.md @@ -0,0 +1,237 @@ +--- +title: "Signal – iOS Quick Connect" +summary: "How to connect a Signal number to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Signal quick-connect screen in the iOS app +--- + +# Signal – iOS Quick Connect + +**Auth method:** QR link (recommended) or phone registration +**Complexity:** High + +Signal integration uses `signal-cli` running inside the container. There are two registration paths available from the iOS app: + +1. **QR link** — Link the container as a secondary device to an existing Signal account by scanning a QR code. The user does not need a separate phone number. +2. **Phone registration** — Register a dedicated phone number with Signal (requires receiving an SMS or voice verification). + +--- + +## Path A: QR link (recommended for iOS app UX) + +This is the simplest path for iOS app users. It links the OpenClaw container as a secondary "linked device" to the user's existing Signal account, similar to Signal Desktop. + +### User flow + +``` +1. User opens Settings → Channels → Signal +2. Taps "Link as Device" +3. iOS app calls the container to start a link session + → Container runs: signal-cli link -n "OpenClaw" + → Returns a linking URI (tsdevice://?uuid=...&pub_key=...) +4. iOS app renders the URI as a QR code +5. User opens Signal on their primary phone: + Settings → Linked Devices → "+" → scan QR +6. Container confirms successful link via polling +7. iOS app shows success with the linked account name +``` + +### iOS implementation + +```swift +struct SignalLinkView: View { + @State private var linkingQR: UIImage? = nil + @State private var status: LinkStatus = .idle + + var body: some View { + VStack(spacing: 24) { + switch status { + case .loading: + ProgressView("Starting link session…") + case .scanning: + if let qr = linkingQR { + QRCodeView(image: qr) + Text("Open Signal → Settings → Linked Devices → tap \"+\"") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + case .linked: + Label("Signal linked!", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .error(let msg): + Label(msg, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + Button("Try again") { Task { await startLink() } } + default: + Button("Link as Signal Device") { Task { await startLink() } } + } + } + .navigationTitle("Link Signal") + } + + private func startLink() async { + status = .loading + do { + // Container returns the tsdevice:// URI as a string + let uri = try await ContainerService.shared.startSignalLink() + // generateQRCode: use a QR library such as CoreImage's CIFilter("CIQRCodeGenerator") + // or a package like https://github.com/fwcd/swift-qrcode-generator + linkingQR = generateQRCode(from: uri) + status = .scanning + Task { await pollForLink() } + } catch { + status = .error(error.localizedDescription) + } + } + + private func pollForLink() async { + for _ in 0..<40 { + try? await Task.sleep(nanoseconds: 3_000_000_000) + if let linked = try? await ContainerService.shared.checkSignalLinkStatus(), linked { + status = .linked + return + } + } + status = .error("Link timed out. Please try again.") + } +} +``` + +### Container endpoints required + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/gateway/signal/link/start` | POST | Runs `signal-cli link` and returns the `tsdevice://` URI | +| `/gateway/signal/link/status` | GET | Returns `{ "status": "pending" \| "linked" \| "error" }` | + +--- + +## Path B: Phone number registration + +For users who want a dedicated Signal bot number (not linked to their personal account). + +### User flow + +``` +1. User opens Settings → Channels → Signal +2. Taps "Register a phone number" +3. Enters the phone number in E.164 format (+15551234567) +4. Selects verification method: SMS or Voice +5. iOS app sends the registration request to the container +6. Container calls Signal's registration API +7. User receives a verification code via SMS or voice call +8. User enters the 6-digit code in the iOS app +9. Container completes registration +10. iOS app shows success +``` + +### iOS implementation + +```swift +struct SignalRegisterView: View { + @State private var phoneNumber = "" + @State private var verificationCode = "" + @State private var step: RegisterStep = .enterPhone + @State private var captchaToken = "" + + var body: some View { + Form { + switch step { + case .enterPhone: + Section(header: Text("Phone Number")) { + TextField("+15551234567", text: $phoneNumber) + .keyboardType(.phonePad) + .textContentType(.telephoneNumber) + } + Section { + Button("Send SMS") { Task { await requestSMS() } } + Button("Request Voice Call") { Task { await requestVoice() } } + } + + case .enterCode: + Section(header: Text("Verification Code")) { + TextField("123456", text: $verificationCode) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + } + Section { + Button("Verify") { Task { await verifyCode() } } + } + } + } + .navigationTitle("Register Signal Number") + } + + private func requestSMS() async { + try? await ContainerService.shared.signalRegister( + phoneNumber: phoneNumber, + method: "sms", + captcha: captchaToken + ) + step = .enterCode + } + + private func verifyCode() async { + try? await ContainerService.shared.signalVerify( + phoneNumber: phoneNumber, + code: verificationCode + ) + } +} +``` + +> **Captcha requirement:** Signal's registration API may require a captcha token. The iOS app should present a WebView loading `https://signalcaptchas.org/registration/generate.html` and capture the resulting token before initiating registration. + +--- + +## Container config applied + +```json5 +{ + channels: { + signal: { + enabled: true, + account: "+15551234567", // registered/linked number + cliPath: "signal-cli", // path inside the container + dmPolicy: "pairing", + allowFrom: [], + }, + }, +} +``` + +The config is applied after successful registration or linking: + +```http +PATCH /config/channels +Authorization: Bearer +Content-Type: application/json + +{ + "channel": "signal", + "config": { + "enabled": true, + "account": "+15551234567", + "cliPath": "signal-cli", + "dmPolicy": "pairing" + } +} +``` + +--- + +## Container requirements + +- `signal-cli` must be pre-installed in the OpenClaw container image. +- Java (JRE 17+) or the native binary variant of `signal-cli` must be available. +- The container must be able to reach Signal's servers over HTTPS. + +--- + +## Security notes + +- The QR link method is strongly preferred for iOS app users. It avoids the complexity of captcha handling and does not require a separate phone number. +- A linked device can read all messages delivered to the primary account. Advise users to use a secondary Signal account if privacy between the bot and personal messages is important. +- Phone number registration permanently associates the number with a Signal identity. This cannot easily be undone. +- Store `signal-cli` data directory within the user's isolated Azure Files volume so it is not accessible to other users. diff --git a/dean/ios-quick-connect/slack.md b/dean/ios-quick-connect/slack.md new file mode 100644 index 000000000000..940b5edd21cf --- /dev/null +++ b/dean/ios-quick-connect/slack.md @@ -0,0 +1,192 @@ +--- +title: "Slack – iOS Quick Connect" +summary: "How to connect a Slack workspace to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Slack quick-connect screen in the iOS app +--- + +# Slack – iOS Quick Connect + +**Auth method:** OAuth in-app (recommended) or token paste +**Complexity:** Medium + +Slack supports two quick-connect paths from the iOS app: + +1. **OAuth "Add to Slack" button** — The user taps a button, authorizes in Slack's web UI (via `ASWebAuthenticationSession`), and the app captures the tokens automatically. This is the smoothest UX and requires a hosted OAuth callback endpoint. +2. **Token paste** — The user manually creates a Slack app, copies the App Token (`xapp-`) and Bot Token (`xoxb-`), and pastes them into text fields. Suitable for power users or when you do not want to host an OAuth callback. + +--- + +## Option A: OAuth "Add to Slack" flow (recommended) + +### User flow + +``` +1. User opens Settings → Channels → Slack +2. Taps "Add to Slack" button +3. ASWebAuthenticationSession opens Slack's OAuth page +4. User selects their workspace and authorizes the app +5. Slack redirects back to the app's callback URL with a code +6. Backend exchanges the code for bot token + app token +7. Tokens are forwarded to the user's container +8. App shows success with workspace name +``` + +### iOS implementation + +```swift +import AuthenticationServices + +struct SlackOAuthView: View { + @State private var status: ConnectionStatus = .idle + + var body: some View { + Button("Add to Slack") { + Task { await startOAuth() } + } + .buttonStyle(.borderedProminent) + .navigationTitle("Connect Slack") + } + + private func startOAuth() async { + let clientId = AppConfig.slackClientId + let redirectURI = "https://files.spark.ooo/oauth/slack/callback" + let scopes = "app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history" + + var components = URLComponents(string: "https://slack.com/oauth/v2/authorize")! + components.queryItems = [ + .init(name: "client_id", value: clientId), + .init(name: "scope", value: scopes), + .init(name: "redirect_uri", value: redirectURI), + .init(name: "state", value: generateStateToken()), + ] + + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: "openclaw" + ) { callbackURL, error in + guard let url = callbackURL else { return } + let code = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value + Task { await self.exchangeCode(code!) } + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = true + session.start() + } + + private func exchangeCode(_ code: String) async { + // The file-api OAuth endpoint handles the Slack code exchange + // and patches the container config automatically. + try? await ChannelConfigService.shared.completeOAuth( + channel: "slack", + code: code + ) + status = .connected + } +} +``` + +### Backend: OAuth callback endpoint (file-api) + +The `files.spark.ooo` file-api pod must implement: + +``` +GET /oauth/slack/callback?code=&state= + +1. Validates the state token against the Supabase session +2. POSTs to https://slack.com/api/oauth.v2.access with code + client secret +3. Extracts bot_token (xoxb-) and app_token (requires separate socket-mode token) +4. PATCHes the user's container config with the tokens +5. Redirects to the app via deep link: openclaw://oauth/slack/success +``` + +--- + +## Option B: Token paste + +### Slack app setup (user does this once) + +| Step | Where | +|------|-------| +| Create a Slack app | [api.slack.com/apps](https://api.slack.com/apps) → **Create New App → From Scratch** | +| Enable Socket Mode | App settings → **Socket Mode** → toggle on | +| Create App Token | App settings → **Basic Information** → App-Level Tokens → **Generate Token** with `connections:write` scope → copy `xapp-...` token | +| Install to workspace | App settings → **Install App** → **Install to Workspace** → copy `xoxb-...` Bot Token | + +### UI (SwiftUI sketch) + +```swift +struct SlackTokenPasteView: View { + @State private var appToken = "" // xapp-... + @State private var botToken = "" // xoxb-... + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section(header: Text("App Token (xapp-)")) { + SecureField("xapp-1-...", text: $appToken) + .textContentType(.password) + .autocorrectionDisabled() + } + Section(header: Text("Bot Token (xoxb-)")) { + SecureField("xoxb-...", text: $botToken) + .textContentType(.password) + .autocorrectionDisabled() + } + Section { + Button("Connect") { + Task { await connect() } + } + .disabled(appToken.isEmpty || botToken.isEmpty) + } + } + .navigationTitle("Connect Slack") + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "slack", + config: [ + "mode": "socket", + "appToken": appToken, + "botToken": botToken, + "enabled": true, + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + slack: { + enabled: true, + mode: "socket", // Socket Mode (no public URL needed) + appToken: "xapp-...", // connections:write + botToken: "xoxb-...", // Bot User OAuth Token + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Security notes + +- Slack bot tokens grant broad workspace access. Scope them as narrowly as possible. +- For the OAuth flow, the `state` parameter must be a cryptographically random token tied to the user's Supabase session to prevent CSRF. +- The file-api callback endpoint must verify the `state` before exchanging the code. +- Never log or display the full token in the iOS UI; mask all but the last 6 characters. diff --git a/dean/ios-quick-connect/telegram.md b/dean/ios-quick-connect/telegram.md new file mode 100644 index 000000000000..082b5f43a5b3 --- /dev/null +++ b/dean/ios-quick-connect/telegram.md @@ -0,0 +1,158 @@ +--- +title: "Telegram – iOS Quick Connect" +summary: "How to connect a Telegram bot to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Telegram quick-connect screen in the iOS app +--- + +# Telegram – iOS Quick Connect + +**Auth method:** Token paste +**Complexity:** Low + +Telegram bot tokens are obtained from **@BotFather** inside Telegram itself. The user creates a bot in under a minute, copies the token, and pastes it into the iOS app. No browser-based OAuth or QR scan is needed. + +--- + +## User flow + +``` +1. User opens Settings → Channels → Telegram +2. iOS app shows a "Connect Telegram" screen with: + - "Open BotFather in Telegram" button (deep-links to tg://resolve?domain=BotFather) + - Instruction card: "Send /newbot, follow prompts, copy the token" + - Secure text field for "Bot Token" +3. User taps "Connect" → token is validated, then saved to the container +4. Success screen shows bot @handle and link to start a conversation +``` + +--- + +## iOS implementation + +### 1. Deep-link to BotFather + +```swift +let botFatherURL = URL(string: "tg://resolve?domain=BotFather")! +if UIApplication.shared.canOpenURL(botFatherURL) { + await UIApplication.shared.open(botFatherURL) +} else { + // Telegram not installed: open web fallback + await UIApplication.shared.open(URL(string: "https://t.me/BotFather")!) +} +``` + +### 2. UI (SwiftUI sketch) + +```swift +struct TelegramConnectView: View { + @State private var botToken = "" + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section { + Button("Open BotFather in Telegram") { + openBotFather() + } + .foregroundStyle(.blue) + } header: { + Text("Step 1 – Create a bot") + } footer: { + Text("Send /newbot, choose a name and username, then copy the token BotFather gives you.") + } + + Section(header: Text("Step 2 – Paste your token")) { + SecureField("123456789:ABCDEFabcdef...", text: $botToken) + .textContentType(.password) + .autocorrectionDisabled() + } + + Section { + Button("Connect") { + Task { await connect() } + } + .disabled(botToken.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect Telegram") + } +} +``` + +### 3. Token validation (optional pre-check) + +```swift +func validateTelegramToken(_ token: String) async throws -> String { + let url = URL(string: "https://api.telegram.org/bot\(token)/getMe")! + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw ChannelError.invalidToken + } + struct TelegramUser: Decodable { struct Result: Decodable { let username: String? } let result: Result } + let me = try JSONDecoder().decode(TelegramUser.self, from: data) + return me.result.username ?? "bot" +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + telegram: { + enabled: true, + botToken: "", + dmPolicy: "pairing", + groups: { "*": { requireMention: true } }, + }, + }, +} +``` + +Sent via the file-api config-patch endpoint: + +```http +PATCH /config/channels +Authorization: Bearer +Content-Type: application/json + +{ + "channel": "telegram", + "config": { + "enabled": true, + "botToken": "", + "dmPolicy": "pairing" + } +} +``` + +--- + +## Approving the first pairing request + +After the container connects to Telegram, the first user to DM the bot will need to be approved (because `dmPolicy` defaults to `pairing`). The iOS app can expose this through a **Pending Approvals** screen that polls the container's pairing list endpoint: + +``` +GET /gateway/pairing?channel=telegram +→ [{ "code": "XXXX", "from": "@alice", "requestedAt": "..." }] +``` + +The user taps **Approve** in the app, which calls: + +``` +POST /gateway/pairing/approve +{ "channel": "telegram", "code": "XXXX" } +``` + +Alternatively, set `dmPolicy: "open"` to skip pairing for trusted single-user deployments. + +--- + +## Security notes + +- The bot token grants full control of the bot; treat it like a password. +- Consider setting `allowFrom` to the user's own Telegram user ID to prevent unauthorized access to the bot. +- Tokens should be stored only on the container side; do not persist them in the iOS Keychain beyond the initial setup POST. diff --git a/dean/ios-quick-connect/tlon.md b/dean/ios-quick-connect/tlon.md new file mode 100644 index 000000000000..c8a3d2f1d049 --- /dev/null +++ b/dean/ios-quick-connect/tlon.md @@ -0,0 +1,188 @@ +--- +title: "Tlon / Urbit – iOS Quick Connect" +summary: "How to connect an Urbit ship (Tlon) to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Tlon / Urbit quick-connect screen in the iOS app +--- + +# Tlon / Urbit – iOS Quick Connect + +**Auth method:** Guided form (ship URL + login code) +**Complexity:** Medium + +Tlon is a decentralized messenger built on Urbit. OpenClaw connects to an Urbit ship using the ship's URL and login code (`+code` from the Dojo). The user must already have a running Urbit ship accessible from the internet (or via a hosted service like Red Horizon). + +--- + +## User flow + +``` +1. User opens Settings → Channels → Tlon +2. iOS app shows a form: + - Text field: "Ship Name" (e.g., ~sampel-palnet) + - Text field: "Ship URL" (e.g., https://sampel-palnet.arvo.network) + - SecureField: "Login Code" (from Dojo: +code) + - Text field: "Owner Ship" (your personal ship, always allowed DMs, optional) +3. User taps "Connect" +4. Container config is patched; gateway authenticates with the ship +5. Success screen shows ship name and connection status +``` + +--- + +## iOS implementation + +### 1. Getting the login code + +The iOS app should explain how to obtain the login code: + +``` +In your Urbit Dojo (terminal), run: + +code +This prints a 4-word code like: lidlut-tabwed-pillex-ridrup +``` + +Alternatively, if the user has the Tlon iOS app installed, they can copy the code from their ship's profile settings. + +### 2. UI (SwiftUI sketch) + +```swift +struct TlonConnectView: View { + @State private var shipName = "" // e.g. ~sampel-palnet + @State private var shipURL = "" // e.g. https://sampel-palnet.arvo.network + @State private var loginCode = "" // lidlut-tabwed-pillex-ridrup + @State private var ownerShip = "" // optional: your personal ~ship + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section(header: Text("Ship Name")) { + TextField("~sampel-palnet", text: $shipName) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + Section(header: Text("Ship URL")) { + TextField("https://sampel-palnet.arvo.network", text: $shipURL) + .textContentType(.URL) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + Section(header: Text("Login Code")) { + SecureField("lidlut-tabwed-pillex-ridrup", text: $loginCode) + .textContentType(.password) + .autocorrectionDisabled() + .autocapitalization(.none) + } footer: { + Text("Run +code in your Urbit Dojo or copy from your ship's settings.") + } + + Section(header: Text("Owner Ship (optional)")) { + TextField("~your-main-ship", text: $ownerShip) + .autocorrectionDisabled() + .autocapitalization(.none) + } footer: { + Text("Your personal ship will always be allowed to DM the bot.") + } + + Section { + Button("Connect") { Task { await connect() } } + .disabled(shipName.isEmpty || shipURL.isEmpty || loginCode.isEmpty) + } + } + .navigationTitle("Connect Tlon") + } + + private func connect() async { + status = .connecting + var config: [String: Any] = [ + "enabled": true, + "ship": shipName, + "url": shipURL, + "code": loginCode, + "dmPolicy": "pairing", + ] + if !ownerShip.isEmpty { config["ownerShip"] = ownerShip } + do { + try await ChannelConfigService.shared.patch(channel: "tlon", config: config) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Hosting options (in-app guide) + +For users who do not yet have a running Urbit ship, the iOS app can link to hosted options: + +| Provider | URL | +|----------|-----| +| Red Horizon (Tlon) | [tlon.io](https://tlon.io) | +| Tirrel | [tirrel.io](https://tirrel.io) | +| Self-hosted | [urbit.org/getting-started](https://urbit.org/getting-started) | + +--- + +## Container config applied + +```json5 +{ + channels: { + tlon: { + enabled: true, + ship: "~sampel-palnet", + url: "https://sampel-palnet.arvo.network", + code: "", + ownerShip: "~your-main-ship", // optional + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Private / LAN ships + +If the ship is running on a private network (localhost, LAN, or internal hostname), the container config must explicitly opt in to allow private network access: + +```json5 +{ + channels: { + tlon: { + url: "http://192.168.1.100:8080", + allowPrivateNetwork: true, + }, + }, +} +``` + +The iOS app should warn the user that this configuration may not be reachable from the cloud container if their ship is on a local network. + +--- + +## Plugin requirement + +Tlon ships as a plugin (`@openclaw/tlon`) and must be installed in the container. The iOS app should check plugin status and offer installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/tlon" } +``` + +--- + +## Security notes + +- The Urbit login code (`+code`) is equivalent to a root password for the ship. Treat it as a secret. +- For the OpenClaw container to reach the ship, the ship must be publicly accessible (or reachable from the container's network). HTTPS is strongly recommended. +- The login code can be rotated by running `|code %reset` in the Urbit Dojo. Update the container config after rotating. +- Do not store the login code in the iOS Keychain after the initial setup POST; it lives only on the container. diff --git a/dean/ios-quick-connect/twitch.md b/dean/ios-quick-connect/twitch.md new file mode 100644 index 000000000000..b27e7484a598 --- /dev/null +++ b/dean/ios-quick-connect/twitch.md @@ -0,0 +1,200 @@ +--- +title: "Twitch – iOS Quick Connect" +summary: "How to connect a Twitch bot account to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Twitch quick-connect screen in the iOS app +--- + +# Twitch – iOS Quick Connect + +**Auth method:** OAuth in-app ("Connect with Twitch") +**Complexity:** Low + +Twitch uses OAuth2 for bot authentication. The iOS app opens Twitch's OAuth authorization page in `ASWebAuthenticationSession`, and the user grants access to a bot account. The app captures the resulting OAuth token automatically. + +--- + +## User flow + +``` +1. User opens Settings → Channels → Twitch +2. iOS app shows: + - "Connect with Twitch" button + - Text field for "Channel to join" (the Twitch streamer's channel, e.g., "vevisk") +3. User taps "Connect with Twitch" +4. ASWebAuthenticationSession opens Twitch OAuth page +5. User authorizes the bot account (chat:read, chat:write scopes) +6. App captures the OAuth token and client ID +7. Container config is patched +8. Success screen shows the bot's Twitch username and target channel +``` + +--- + +## iOS implementation + +### 1. OAuth setup + +Register an application at [dev.twitch.tv/console](https://dev.twitch.tv/console) with: +- OAuth Redirect URLs: `openclaw://twitch/callback` +- Category: Chat Bot + +```swift +struct TwitchConnectView: View { + @State private var channelName = "" // Streamer's channel to join + @State private var status: ConnectionStatus = .idle + @State private var connectedAs: String? = nil + + var body: some View { + Form { + Section(header: Text("Channel to join")) { + TextField("vevisk", text: $channelName) + .autocorrectionDisabled() + .autocapitalization(.none) + } + Section { + Button("Connect with Twitch") { + Task { await startOAuth() } + } + .disabled(channelName.isEmpty || status == .connecting) + .buttonStyle(.borderedProminent) + + if let username = connectedAs { + Label("Connected as @\(username)", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + } + .navigationTitle("Connect Twitch") + } + + private func startOAuth() async { + let clientId = AppConfig.twitchClientId + let redirectURI = "openclaw://twitch/callback" + let scopes = "chat:read chat:write" + + var components = URLComponents(string: "https://id.twitch.tv/oauth2/authorize")! + components.queryItems = [ + .init(name: "client_id", value: clientId), + .init(name: "redirect_uri", value: redirectURI), + .init(name: "response_type", value: "token"), // Implicit flow for chat bots + .init(name: "scope", value: scopes), + .init(name: "state", value: generateStateToken()), + .init(name: "force_verify", value: "true"), + ] + + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: "openclaw" + ) { callbackURL, error in + guard let url = callbackURL else { return } + // Twitch puts the token in the URL fragment for implicit flow + let fragment = url.fragment ?? "" + let params = parseQueryString(fragment) + guard let accessToken = params["access_token"] else { return } + Task { await self.finalize(accessToken: accessToken) } + } + session.prefersEphemeralWebBrowserSession = true + session.start() + } + + private func finalize(accessToken: String) async { + // Validate and get bot username + let username = try? await validateTwitchToken(accessToken) + connectedAs = username + + try? await ChannelConfigService.shared.patch( + channel: "twitch", + config: [ + "enabled": true, + "accessToken": "oauth:\(accessToken)", + "clientId": AppConfig.twitchClientId, + "channel": channelName, + "username": username ?? "", + ] + ) + status = .connected + } +} +``` + +### 2. Token validation + +```swift +func validateTwitchToken(_ token: String) async throws -> String { + var request = URLRequest(url: URL(string: "https://id.twitch.tv/oauth2/validate")!) + request.setValue("OAuth \(token)", forHTTPHeaderField: "Authorization") + let (data, _) = try await URLSession.shared.data(for: request) + struct ValidationResponse: Decodable { let login: String; let user_id: String } + let resp = try JSONDecoder().decode(ValidationResponse.self, from: data) + return resp.login +} +``` + +--- + +## Container config applied + +```json5 +{ + channels: { + twitch: { + enabled: true, + username: "openclaw_bot", // Bot's Twitch account + accessToken: "oauth:xxxxxx...", // OAuth Access Token + clientId: "xxxxxxxxxxxxxxxxxx", // App Client ID + channel: "vevisk", // Streamer's channel to join + requireMention: true, // Only respond when @mentioned + allowFrom: ["123456789"], // Optional: streamer's user ID only + }, + }, +} +``` + +--- + +## Plugin requirement + +Twitch ships as a plugin (`@openclaw/twitch`) and must be installed in the container before enabling the channel. The iOS app should check plugin status and offer installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/twitch" } +``` + +--- + +## Access control + +Twitch chat is public. The iOS app settings screen should include an **allowFrom** field so the user can restrict who can interact with the bot. Recommended defaults: + +- `requireMention: true` — Bot only responds when explicitly mentioned. +- `allowFrom: []` — Limit to the channel owner. + +The iOS app can look up the streamer's Twitch user ID from their username: + +```swift +func getTwitchUserId(username: String, clientId: String, token: String) async throws -> String { + var request = URLRequest(url: URL(string: "https://api.twitch.tv/helix/users?login=\(username)")!) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue(clientId, forHTTPHeaderField: "Client-Id") + let (data, _) = try await URLSession.shared.data(for: request) + struct UsersResponse: Decodable { + struct User: Decodable { let id: String } + let data: [User] + } + return try JSONDecoder().decode(UsersResponse.self, from: data).data.first.map { $0.id } ?? { throw ChannelError.custom("Twitch user '\(username)' not found") }() +} +``` + +--- + +## Security notes + +- Twitch OAuth tokens for chat bots require only `chat:read` and `chat:write` scopes; do not request broader permissions. +- Always set `requireMention: true` in the config to prevent the bot from responding to every chat message. +- Store the OAuth token only on the container side; do not persist it in the iOS Keychain after the initial POST. +- Twitch OAuth tokens expire after approximately 60 days. The iOS app settings screen should surface a "Re-authorize" button that runs the OAuth flow again. diff --git a/dean/ios-quick-connect/whatsapp.md b/dean/ios-quick-connect/whatsapp.md new file mode 100644 index 000000000000..2ffffc7b1e5b --- /dev/null +++ b/dean/ios-quick-connect/whatsapp.md @@ -0,0 +1,171 @@ +--- +title: "WhatsApp – iOS Quick Connect" +summary: "How to link a WhatsApp account to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the WhatsApp quick-connect screen in the iOS app +--- + +# WhatsApp – iOS Quick Connect + +**Auth method:** QR scan +**Complexity:** Medium + +OpenClaw uses the WhatsApp Web protocol (Baileys) to link to a WhatsApp account. This is the same mechanism as WhatsApp Web/Desktop: the container generates a QR code and the user scans it with their phone's WhatsApp app. No API keys or developer accounts are required. + +> **Note:** The container must be running and reachable for the QR code flow to work. If the container is not yet assigned or healthy, show a loading state and retry. + +--- + +## User flow + +``` +1. User opens Settings → Channels → WhatsApp +2. iOS app calls the container's link-initiation endpoint + → Container starts a new Baileys session and returns a QR code (base64 PNG or raw QR data) +3. iOS app renders the QR code +4. User opens WhatsApp on their primary phone: + Settings → Linked Devices → Link a Device → scan QR +5. Container detects successful link and confirms to the iOS app via SSE or polling +6. iOS app shows success with the linked phone number +``` + +--- + +## iOS implementation + +### 1. Request a QR code from the container + +The file-api pod (or a dedicated container gateway endpoint) generates the QR code: + +```swift +struct WhatsAppQRView: View { + @State private var qrImage: UIImage? = nil + @State private var status: LinkStatus = .loading + @State private var pollTask: Task? = nil + + var body: some View { + VStack(spacing: 24) { + if let qr = qrImage { + Image(uiImage: qr) + .interpolation(.none) + .resizable() + .frame(width: 260, height: 260) + .padding() + .background(Color.white) + .cornerRadius(12) + + Text("Open WhatsApp → Settings → Linked Devices → Link a Device") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } else if status == .loading { + ProgressView("Generating QR code…") + } else if status == .linked { + Label("WhatsApp linked!", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else if status == .expired { + Button("Refresh QR Code") { + Task { await loadQR() } + } + } + } + .navigationTitle("Link WhatsApp") + .task { await loadQR() } + .onDisappear { pollTask?.cancel() } + } + + private func loadQR() async { + status = .loading + do { + let response = try await ContainerService.shared.initiateWhatsAppLink() + guard let qrData = Data(base64Encoded: response.qrBase64), + let image = UIImage(data: qrData) else { + status = .error + return + } + qrImage = image + status = .scanning + pollTask = Task { await pollLinkStatus() } + } catch { + status = .error + } + } + + private func pollLinkStatus() async { + // Poll every 3 seconds; QR expires after ~60 seconds + for _ in 0..<20 { + try? await Task.sleep(nanoseconds: 3_000_000_000) + let linked = try? await ContainerService.shared.checkWhatsAppLinkStatus() + if linked == true { + status = .linked + return + } + } + // QR expired + qrImage = nil + status = .expired + } +} +``` + +### 2. Container endpoints required + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/gateway/whatsapp/link/start` | POST | Starts a new Baileys session and returns a base64 QR code image | +| `/gateway/whatsapp/link/status` | GET | Returns `{ "status": "pending" \| "linked" \| "expired" }` | +| `/gateway/whatsapp/link/cancel` | POST | Cancels the pending link session | + +These endpoints are authenticated with the user's Supabase JWT and proxied through the file-api pod. + +### 3. QR refresh / expiry + +WhatsApp QR codes expire after approximately 60 seconds. The iOS app should: +- Show a countdown timer. +- Automatically request a fresh QR after expiry. +- Limit refresh attempts to avoid rate-limiting by the WhatsApp backend. + +--- + +## Container config applied + +WhatsApp does not require a static token config. Once the QR is scanned the Baileys session credentials are stored in the container's `~/.openclaw/sessions/whatsapp/` directory. However, the access policy config should be written: + +```json5 +{ + channels: { + whatsapp: { + enabled: true, + dmPolicy: "pairing", + allowFrom: [], // populated after first approved pairing + }, + }, +} +``` + +--- + +## Approving the first message (pairing) + +After linking, the first WhatsApp DM to the bot number will generate a pairing request. The iOS app should surface pending pairing requests through a notification or an in-app badge, identical to the Telegram/Discord flows. + +--- + +## Alternative: WhatsApp Business API + +For production deployments at scale, consider using the **WhatsApp Cloud API** (Meta) instead of Baileys. This requires: + +- A Meta Business account and a verified WhatsApp Business number. +- A permanent API token (System User token). +- A publicly reachable webhook URL for inbound messages. + +The iOS quick-connect flow for Cloud API would use **token paste** (permanent system user token + phone number ID), which is lower complexity than the QR scan. See Meta's documentation for [WhatsApp Cloud API setup](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started). + +--- + +## Security notes + +- The linked WhatsApp session is equivalent to a logged-in WhatsApp Web session. It grants access to the account's full message history. +- Encourage users to use a dedicated WhatsApp number (not their personal number) to avoid accidental message interception. +- The container should enforce `allowFrom` to whitelist only authorized phone numbers. +- QR code endpoint responses must be scoped to the authenticated user's container only; never expose another user's QR data. diff --git a/dean/ios-quick-connect/zalo.md b/dean/ios-quick-connect/zalo.md new file mode 100644 index 000000000000..338cfb51dcf1 --- /dev/null +++ b/dean/ios-quick-connect/zalo.md @@ -0,0 +1,148 @@ +--- +title: "Zalo – iOS Quick Connect" +summary: "How to connect a Zalo Official Account to a user's OpenClaw container from the iOS app" +read_when: + - Implementing the Zalo quick-connect screen in the iOS app +--- + +# Zalo – iOS Quick Connect + +**Auth method:** Token paste +**Complexity:** Low + +Zalo is a Vietnamese messaging platform widely used in Vietnam. OpenClaw connects to it via the **Zalo Official Account (OA)** API using a bot token issued by the Zalo developer platform. The user creates an Official Account, generates an API token, and pastes it into the iOS app. + +--- + +## User flow + +``` +1. User opens Settings → Channels → Zalo +2. iOS app shows: + - Link to developers.zalo.me (SFSafariViewController) + - SecureField for "OA Access Token" +3. User creates a Zalo Official Account and generates an access token +4. Pastes the token and taps "Connect" +5. Container config is patched; gateway connects to Zalo OA webhook +6. Success screen shows the OA name +``` + +--- + +## iOS implementation + +### 1. UI (SwiftUI sketch) + +```swift +struct ZaloConnectView: View { + @State private var oaAccessToken = "" + @State private var status: ConnectionStatus = .idle + + var body: some View { + Form { + Section { + Link("Open Zalo Developer Portal", + destination: URL(string: "https://developers.zalo.me")!) + } header: { + Text("Step 1 – Create an Official Account") + } footer: { + Text("Create a Zalo Official Account → go to Settings → Permissions → generate an Access Token.") + } + + Section(header: Text("Step 2 – Paste your OA Access Token")) { + SecureField("OA Access Token", text: $oaAccessToken) + .textContentType(.password) + .autocorrectionDisabled() + } + + Section { + Button("Connect") { Task { await connect() } } + .disabled(oaAccessToken.isEmpty || status == .connecting) + } + } + .navigationTitle("Connect Zalo") + } + + private func connect() async { + status = .connecting + do { + try await ChannelConfigService.shared.patch( + channel: "zalo", + config: [ + "enabled": true, + "accounts": [ + "default": ["botToken": oaAccessToken] + ], + ] + ) + status = .connected + } catch { + status = .error(error.localizedDescription) + } + } +} +``` + +--- + +## Zalo OA setup guide (in-app) + +The iOS app should include a brief guide (shown as a collapsible card): + +1. Go to [developers.zalo.me](https://developers.zalo.me) and sign in. +2. Create a new **Official Account** application. +3. Go to **Settings → Access Token** and generate a token. +4. Copy the token and paste it into the iOS app. + +--- + +## Container config applied + +```json5 +{ + channels: { + zalo: { + enabled: true, + accounts: { + default: { + botToken: "", + }, + }, + dmPolicy: "pairing", + }, + }, +} +``` + +--- + +## Plugin requirement + +Zalo ships as a plugin (`@openclaw/zalo`) and must be installed in the container. The iOS app should check plugin status and offer installation: + +```http +POST /gateway/plugins/install +Authorization: Bearer +Content-Type: application/json + +{ "package": "@openclaw/zalo" } +``` + +--- + +## Webhook exposure + +Zalo Official Account webhooks require a publicly accessible HTTPS URL. The container must expose its webhook endpoint. Options: + +- **Cloudflare Tunnel** (recommended) — The container's gateway port is exposed via Cloudflare Tunnel, and the tunnel URL is registered in the Zalo developer portal. +- **AKS Ingress** — Per-user subdomain. + +The iOS app setup screen should display the container's public webhook URL so the user can register it in the Zalo developer portal. + +--- + +## Security notes + +- Zalo OA access tokens expire periodically. The iOS app settings screen should surface a "Refresh token" action. +- Store the token only on the container side; do not persist it in the iOS Keychain. +- Webhook endpoints should be authenticated to prevent unauthorized message injection.