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>
This commit is contained in:
George McCain
2026-02-13 15:51:57 -05:00
committed by Peter Steinberger
parent 95024d1671
commit d4a142fd8f
17 changed files with 1392 additions and 15 deletions

View File

@@ -18,6 +18,7 @@ import {
} from "../config/group-policy.js";
import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveLinqAccount } from "../linq/accounts.js";
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveSignalAccount } from "../signal/accounts.js";
@@ -450,6 +451,23 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
buildDirectOrGroupThreadToolContext({ context, hasRepliedRef }),
},
},
linq: {
id: "linq",
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveLinqAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
};
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {

View File

@@ -13,6 +13,7 @@ export const CHAT_CHANNEL_ORDER = [
"slack",
"signal",
"imessage",
"linq",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
@@ -109,6 +110,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
blurb: "this is still a work in progress.",
systemImage: "message.fill",
},
linq: {
id: "linq",
label: "Linq",
selectionLabel: "Linq (iMessage API)",
detailLabel: "Linq iMessage",
docsPath: "/channels/linq",
docsLabel: "linq",
blurb: "real iMessage blue bubbles via API — no Mac required. Get a token at linqapp.com.",
systemImage: "bubble.left.and.text.bubble.right",
},
};
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
@@ -116,6 +127,7 @@ export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
"internet-relay-chat": "irc",
"google-chat": "googlechat",
gchat: "googlechat",
"linq-imessage": "linq",
};
const normalizeChannelKey = (raw?: string | null): string | undefined => {