Files
openclaw/src/linq/accounts.ts
George McCain d4a142fd8f feat: add Linq channel — real iMessage via API, no Mac required
Adds a complete Linq iMessage channel adapter that replaces the existing
iMessage channel's Mac Mini + dedicated Apple ID + SSH wrapper + Full Disk
Access setup with a single API key and phone number.

Core implementation (src/linq/):
- types.ts: Linq webhook event and message types
- accounts.ts: Multi-account resolution from config (env/file/inline token)
- send.ts: REST outbound via Linq Blue V3 API (messages, typing, reactions)
- probe.ts: Health check via GET /v3/phonenumbers
- monitor.ts: Webhook HTTP server with HMAC-SHA256 signature verification,
  replay protection, inbound debouncing, and full dispatch pipeline integration

Extension plugin (extensions/linq/):
- ChannelPlugin implementation with config, security, setup, outbound,
  gateway, and status adapters
- Supports direct and group chats, reactions, and media

Wiring:
- Channel registry, dock, config schema, plugin-sdk exports, and plugin
  runtime all updated to include the new linq channel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:52:56 +01:00

113 lines
3.6 KiB
TypeScript

import { readFileSync } from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import type { LinqAccountConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export type ResolvedLinqAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "config" | "env" | "file" | "none";
fromPhone?: string;
config: LinqAccountConfig;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| LinqAccountConfig
| undefined;
if (!accounts?.accounts || typeof accounts.accounts !== "object") {
return [];
}
return Object.keys(accounts.accounts).filter(Boolean);
}
export function listLinqAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultLinqAccountId(cfg: OpenClawConfig): string {
const ids = listLinqAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): LinqAccountConfig | undefined {
const linqSection = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| LinqAccountConfig
| undefined;
if (!linqSection?.accounts || typeof linqSection.accounts !== "object") {
return undefined;
}
return linqSection.accounts[accountId];
}
function mergeLinqAccountConfig(cfg: OpenClawConfig, accountId: string): LinqAccountConfig {
const linqSection = (cfg.channels as Record<string, unknown> | undefined)?.linq as
| (LinqAccountConfig & { accounts?: unknown })
| undefined;
const { accounts: _ignored, ...base } = linqSection ?? {};
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveToken(
merged: LinqAccountConfig,
accountId: string,
): { token: string; source: "config" | "env" | "file" } | { token: ""; source: "none" } {
// Environment variable takes priority for the default account.
const envToken = process.env.LINQ_API_TOKEN?.trim() ?? "";
if (envToken && accountId === DEFAULT_ACCOUNT_ID) {
return { token: envToken, source: "env" };
}
// Config token.
if (merged.apiToken?.trim()) {
return { token: merged.apiToken.trim(), source: "config" };
}
// Token file (read synchronously to keep resolver sync-friendly).
if (merged.tokenFile?.trim()) {
try {
const content = readFileSync(merged.tokenFile.trim(), "utf8").trim();
if (content) {
return { token: content, source: "file" };
}
} catch {
// fall through
}
}
return { token: "", source: "none" };
}
export function resolveLinqAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedLinqAccount {
const accountId = normalizeAccountId(params.accountId);
const linqSection = (params.cfg.channels as Record<string, unknown> | undefined)?.linq as
| LinqAccountConfig
| undefined;
const baseEnabled = linqSection?.enabled !== false;
const merged = mergeLinqAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const { token, source } = resolveToken(merged, accountId);
return {
accountId,
enabled: baseEnabled && accountEnabled,
name: merged.name?.trim() || undefined,
token,
tokenSource: source,
fromPhone: merged.fromPhone?.trim() || undefined,
config: merged,
};
}