@listeningkit/telnyx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +263 -0
- package/banner.png +0 -0
- package/client.ts +98 -0
- package/convex.config.ts +11 -0
- package/dist/_generated/api.d.ts +40 -0
- package/dist/_generated/api.d.ts.map +1 -0
- package/dist/_generated/api.js +31 -0
- package/dist/_generated/api.js.map +1 -0
- package/dist/_generated/component.d.ts +68 -0
- package/dist/_generated/component.d.ts.map +1 -0
- package/dist/_generated/component.js +11 -0
- package/dist/_generated/component.js.map +1 -0
- package/dist/_generated/dataModel.d.ts +46 -0
- package/dist/_generated/dataModel.d.ts.map +1 -0
- package/dist/_generated/dataModel.js +11 -0
- package/dist/_generated/dataModel.js.map +1 -0
- package/dist/_generated/server.d.ts +137 -0
- package/dist/_generated/server.d.ts.map +1 -0
- package/dist/_generated/server.js +80 -0
- package/dist/_generated/server.js.map +1 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +29 -0
- package/dist/client.js.map +1 -0
- package/dist/convex.config.d.ts +8 -0
- package/dist/convex.config.d.ts.map +1 -0
- package/dist/convex.config.js +11 -0
- package/dist/convex.config.js.map +1 -0
- package/dist/lib/telnyx.d.ts +63 -0
- package/dist/lib/telnyx.d.ts.map +1 -0
- package/dist/lib/telnyx.js +102 -0
- package/dist/lib/telnyx.js.map +1 -0
- package/dist/schema.d.ts +38 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +24 -0
- package/dist/schema.js.map +1 -0
- package/dist/telnyx.d.ts +71 -0
- package/dist/telnyx.d.ts.map +1 -0
- package/dist/telnyx.js +170 -0
- package/dist/telnyx.js.map +1 -0
- package/dist/test.d.ts +60 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +28 -0
- package/dist/test.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/lib/telnyx.ts +143 -0
- package/package.json +104 -0
- package/schema.ts +25 -0
- package/telnyx.ts +182 -0
- package/test.ts +35 -0
package/lib/telnyx.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
|
|
3
|
+
export type TelnyxEventType = "message.received" | "message.sent" | "message.finalized";
|
|
4
|
+
|
|
5
|
+
export type TelnyxMessagePayload = {
|
|
6
|
+
id: string;
|
|
7
|
+
direction: "inbound" | "outbound";
|
|
8
|
+
to: Array<{ phone_number: string; status?: string }>;
|
|
9
|
+
from?: { phone_number: string; carrier?: string; line_type?: string };
|
|
10
|
+
text?: string;
|
|
11
|
+
media?: Array<{ url: string; content_type?: string; size?: number }>;
|
|
12
|
+
errors?: Array<{ code: string; title?: string; detail?: string }>;
|
|
13
|
+
completed_at?: string | null;
|
|
14
|
+
messaging_profile_id?: string;
|
|
15
|
+
received_at?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TelnyxWebhookEvent = {
|
|
19
|
+
event_type: TelnyxEventType;
|
|
20
|
+
id: string;
|
|
21
|
+
occurred_at?: string;
|
|
22
|
+
payload: TelnyxMessagePayload;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type TelnyxSendInput = {
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
text: string;
|
|
29
|
+
webhook_url?: string;
|
|
30
|
+
media_urls?: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type TelnyxSendResult = {
|
|
34
|
+
id: string;
|
|
35
|
+
status: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class TelnyxProviderError extends Error {
|
|
39
|
+
constructor(
|
|
40
|
+
message: string,
|
|
41
|
+
readonly status: number,
|
|
42
|
+
) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "TelnyxProviderError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
49
|
+
const MAX_WEBHOOK_AGE_MS = 5 * 60 * 1000;
|
|
50
|
+
const EVENT_TYPES = new Set<TelnyxEventType>(["message.received", "message.sent", "message.finalized"]);
|
|
51
|
+
|
|
52
|
+
function requireE164(value: string, field: string): string {
|
|
53
|
+
if (!E164.test(value)) throw new TelnyxProviderError(`${field} must be E.164 formatted`, 0);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseMessagePayload(value: unknown): TelnyxMessagePayload {
|
|
58
|
+
if (typeof value !== "object" || value === null) throw new TelnyxProviderError("Telnyx payload is missing", 0);
|
|
59
|
+
const payload = value as Partial<TelnyxMessagePayload>;
|
|
60
|
+
if (typeof payload.id !== "string" || !Array.isArray(payload.to)) {
|
|
61
|
+
throw new TelnyxProviderError("Telnyx payload has an unexpected shape", 0);
|
|
62
|
+
}
|
|
63
|
+
return payload as TelnyxMessagePayload;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseTelnyxWebhook(rawBody: string): TelnyxWebhookEvent {
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(rawBody);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new TelnyxProviderError("Telnyx webhook body is not JSON", 0);
|
|
72
|
+
}
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null) throw new TelnyxProviderError("Telnyx webhook body is invalid", 0);
|
|
74
|
+
const data = (parsed as { data?: unknown }).data;
|
|
75
|
+
if (typeof data !== "object" || data === null) throw new TelnyxProviderError("Telnyx webhook data is missing", 0);
|
|
76
|
+
const event = data as { event_type?: unknown; id?: unknown; occurred_at?: unknown; payload?: unknown };
|
|
77
|
+
if (typeof event.event_type !== "string" || !EVENT_TYPES.has(event.event_type as TelnyxEventType)) {
|
|
78
|
+
throw new TelnyxProviderError("Telnyx webhook event type is unsupported", 0);
|
|
79
|
+
}
|
|
80
|
+
if (typeof event.id !== "string") throw new TelnyxProviderError("Telnyx webhook event id is missing", 0);
|
|
81
|
+
return {
|
|
82
|
+
event_type: event.event_type as TelnyxEventType,
|
|
83
|
+
id: event.id,
|
|
84
|
+
occurred_at: typeof event.occurred_at === "string" ? event.occurred_at : undefined,
|
|
85
|
+
payload: parseMessagePayload(event.payload),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function decodeBase64(value: string): Uint8Array {
|
|
90
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function sendTelnyxSms(input: {
|
|
94
|
+
apiKey?: string;
|
|
95
|
+
baseUrl?: string;
|
|
96
|
+
fetcher?: typeof fetch;
|
|
97
|
+
message: TelnyxSendInput;
|
|
98
|
+
}): Promise<TelnyxSendResult> {
|
|
99
|
+
const apiKey = input.apiKey?.trim() ?? "";
|
|
100
|
+
if (!apiKey) throw new TelnyxProviderError("TELNYX_API_KEY is not set", 0);
|
|
101
|
+
const baseUrl = (input.baseUrl ?? "https://api.telnyx.com/v2").replace(/\/+$/, "");
|
|
102
|
+
const fetcher = input.fetcher ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
|
|
103
|
+
const from = requireE164(input.message.from, "from");
|
|
104
|
+
const to = requireE164(input.message.to, "to");
|
|
105
|
+
if (!input.message.text.trim()) throw new TelnyxProviderError("Message text is required", 0);
|
|
106
|
+
if (input.message.text.length > 1600) throw new TelnyxProviderError("Message text is too long", 0);
|
|
107
|
+
|
|
108
|
+
const response = await fetcher(`${baseUrl}/messages`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify({ ...input.message, from, to }),
|
|
112
|
+
signal: AbortSignal.timeout(15_000),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const detail = await response.text().catch(() => "");
|
|
116
|
+
throw new TelnyxProviderError(`Telnyx send failed (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ""}`, response.status);
|
|
117
|
+
}
|
|
118
|
+
const body = (await response.json()) as { data?: { id?: unknown; to?: Array<{ status?: unknown }> } };
|
|
119
|
+
if (typeof body.data?.id !== "string") throw new TelnyxProviderError("Telnyx send response has no message id", response.status);
|
|
120
|
+
const status = body.data?.to?.[0]?.status;
|
|
121
|
+
return { id: body.data.id, status: typeof status === "string" ? status : "queued" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function verifyTelnyxWebhook(input: {
|
|
125
|
+
rawBody: string;
|
|
126
|
+
headers: Record<string, string>;
|
|
127
|
+
publicKey?: string;
|
|
128
|
+
now?: number;
|
|
129
|
+
}): TelnyxWebhookEvent | null {
|
|
130
|
+
const timestamp = Number(input.headers["telnyx-timestamp"]);
|
|
131
|
+
const signature = input.headers["telnyx-signature-ed25519"];
|
|
132
|
+
const publicKey = input.publicKey?.trim() ?? "";
|
|
133
|
+
const now = input.now ?? Date.now();
|
|
134
|
+
if (!timestamp || !signature || !publicKey) return null;
|
|
135
|
+
if (Math.abs(now - timestamp * 1000) > MAX_WEBHOOK_AGE_MS) return null;
|
|
136
|
+
try {
|
|
137
|
+
const message = new TextEncoder().encode(`${timestamp}|${input.rawBody}`);
|
|
138
|
+
const valid = nacl.sign.detached.verify(message, decodeBase64(signature), decodeBase64(publicKey));
|
|
139
|
+
return valid ? parseTelnyxWebhook(input.rawBody) : null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@listeningkit/telnyx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Production Convex component for Telnyx. Send SMS, verify Ed25519-signed webhooks, and keep every message idempotent through one typed API.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"convex",
|
|
7
|
+
"convex-component",
|
|
8
|
+
"telnyx",
|
|
9
|
+
"sms",
|
|
10
|
+
"messaging",
|
|
11
|
+
"webhooks",
|
|
12
|
+
"ed25519",
|
|
13
|
+
"idempotency",
|
|
14
|
+
"a2p",
|
|
15
|
+
"10dlc"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/matthewdonsemail-lab/convex-telnyx#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/matthewdonsemail-lab/convex-telnyx.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/matthewdonsemail-lab/convex-telnyx/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "ListeningKit",
|
|
28
|
+
"url": "https://listeningkit.ai"
|
|
29
|
+
},
|
|
30
|
+
"maintainers": [
|
|
31
|
+
{
|
|
32
|
+
"name": "matthewdonse",
|
|
33
|
+
"email": "matthewdonsemail@gmail.com"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"type": "module",
|
|
40
|
+
"main": "./dist/client.js",
|
|
41
|
+
"types": "./dist/client.d.ts",
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"convex.config.ts",
|
|
45
|
+
"schema.ts",
|
|
46
|
+
"telnyx.ts",
|
|
47
|
+
"client.ts",
|
|
48
|
+
"test.ts",
|
|
49
|
+
"lib/telnyx.ts",
|
|
50
|
+
"banner.png",
|
|
51
|
+
"README.md"
|
|
52
|
+
],
|
|
53
|
+
"exports": {
|
|
54
|
+
"./package.json": "./package.json",
|
|
55
|
+
".": {
|
|
56
|
+
"types": "./dist/client.d.ts",
|
|
57
|
+
"default": "./dist/client.js"
|
|
58
|
+
},
|
|
59
|
+
"./client": {
|
|
60
|
+
"types": "./dist/client.d.ts",
|
|
61
|
+
"default": "./dist/client.js"
|
|
62
|
+
},
|
|
63
|
+
"./convex.config": {
|
|
64
|
+
"types": "./dist/convex.config.d.ts",
|
|
65
|
+
"default": "./dist/convex.config.js"
|
|
66
|
+
},
|
|
67
|
+
"./convex.config.js": {
|
|
68
|
+
"types": "./dist/convex.config.d.ts",
|
|
69
|
+
"default": "./dist/convex.config.js"
|
|
70
|
+
},
|
|
71
|
+
"./_generated/component.js": {
|
|
72
|
+
"types": "./dist/_generated/component.d.ts"
|
|
73
|
+
},
|
|
74
|
+
"./_generated/component": {
|
|
75
|
+
"types": "./dist/_generated/component.d.ts"
|
|
76
|
+
},
|
|
77
|
+
"./test": "./test.ts"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "tsc --build ./tsconfig.build.json --force",
|
|
81
|
+
"build:clean": "rm -rf dist && npm run build",
|
|
82
|
+
"build:codegen": "convex codegen --component-dir ./",
|
|
83
|
+
"typecheck": "tsc --noEmit --skipLibCheck",
|
|
84
|
+
"test": "vitest run",
|
|
85
|
+
"prepare": "npm run build"
|
|
86
|
+
},
|
|
87
|
+
"peerDependencies": {
|
|
88
|
+
"convex": ">=1.45.0"
|
|
89
|
+
},
|
|
90
|
+
"dependencies": {
|
|
91
|
+
"tweetnacl": "^1.0.3"
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@types/node": "^22",
|
|
95
|
+
"convex": "^1.46.0",
|
|
96
|
+
"convex-test": "^0.0.60",
|
|
97
|
+
"typescript": "^5.6.3",
|
|
98
|
+
"vite": "^8.3.1",
|
|
99
|
+
"vitest": "^5.0.1"
|
|
100
|
+
},
|
|
101
|
+
"publishConfig": {
|
|
102
|
+
"access": "public"
|
|
103
|
+
}
|
|
104
|
+
}
|
package/schema.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
messages: defineTable({
|
|
6
|
+
owner: v.string(),
|
|
7
|
+
providerMessageId: v.string(),
|
|
8
|
+
to: v.string(),
|
|
9
|
+
text: v.string(),
|
|
10
|
+
status: v.string(),
|
|
11
|
+
createdAt: v.number(),
|
|
12
|
+
})
|
|
13
|
+
.index("by_owner", ["owner"])
|
|
14
|
+
.index("by_provider_message", ["providerMessageId"]),
|
|
15
|
+
|
|
16
|
+
webhookEvents: defineTable({
|
|
17
|
+
eventId: v.string(),
|
|
18
|
+
eventType: v.string(),
|
|
19
|
+
payload: v.any(),
|
|
20
|
+
occurredAt: v.optional(v.string()),
|
|
21
|
+
receivedAt: v.number(),
|
|
22
|
+
})
|
|
23
|
+
.index("by_event", ["eventId"])
|
|
24
|
+
.index("by_received", ["receivedAt"]),
|
|
25
|
+
});
|
package/telnyx.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ConvexError, v } from "convex/values";
|
|
2
|
+
import { action, env, internalMutation, query } from "./_generated/server.js";
|
|
3
|
+
import { internal } from "./_generated/api.js";
|
|
4
|
+
import {
|
|
5
|
+
sendTelnyxSms,
|
|
6
|
+
verifyTelnyxWebhook,
|
|
7
|
+
type TelnyxSendResult,
|
|
8
|
+
type TelnyxWebhookEvent,
|
|
9
|
+
} from "./lib/telnyx.js";
|
|
10
|
+
|
|
11
|
+
const messageRow = v.object({
|
|
12
|
+
_id: v.string(),
|
|
13
|
+
_creationTime: v.number(),
|
|
14
|
+
owner: v.string(),
|
|
15
|
+
providerMessageId: v.string(),
|
|
16
|
+
to: v.string(),
|
|
17
|
+
text: v.string(),
|
|
18
|
+
status: v.string(),
|
|
19
|
+
createdAt: v.number(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const webhookEventRow = v.object({
|
|
23
|
+
_id: v.string(),
|
|
24
|
+
_creationTime: v.number(),
|
|
25
|
+
eventId: v.string(),
|
|
26
|
+
eventType: v.string(),
|
|
27
|
+
payload: v.any(),
|
|
28
|
+
occurredAt: v.optional(v.string()),
|
|
29
|
+
receivedAt: v.number(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const webhookEvent = v.union(
|
|
33
|
+
v.null(),
|
|
34
|
+
v.object({
|
|
35
|
+
event_type: v.string(),
|
|
36
|
+
id: v.string(),
|
|
37
|
+
occurred_at: v.optional(v.string()),
|
|
38
|
+
payload: v.any(),
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send an SMS through `POST /v2/messages` and record it in the component's
|
|
44
|
+
* isolated `messages` table, keyed by the Telnyx message id so a retried send
|
|
45
|
+
* never double-logs.
|
|
46
|
+
*
|
|
47
|
+
* Auth lives in the app: components have no `ctx.auth`, so the caller passes
|
|
48
|
+
* its already-verified owner string. The API key and default sender number
|
|
49
|
+
* come only from the component's declared env, never from arguments.
|
|
50
|
+
*/
|
|
51
|
+
export const sendSms = action({
|
|
52
|
+
args: {
|
|
53
|
+
owner: v.string(),
|
|
54
|
+
from: v.optional(v.string()),
|
|
55
|
+
to: v.string(),
|
|
56
|
+
text: v.string(),
|
|
57
|
+
webhookUrl: v.optional(v.string()),
|
|
58
|
+
},
|
|
59
|
+
returns: v.object({ id: v.string(), status: v.string() }),
|
|
60
|
+
handler: async (ctx, args): Promise<TelnyxSendResult> => {
|
|
61
|
+
const from = args.from ?? env.TELNYX_FROM_NUMBER;
|
|
62
|
+
if (!from) throw new ConvexError("TELNYX_FROM_NUMBER is not configured");
|
|
63
|
+
const result = await sendTelnyxSms({
|
|
64
|
+
apiKey: env.TELNYX_API_KEY,
|
|
65
|
+
baseUrl: env.TELNYX_API_BASE_URL,
|
|
66
|
+
message: {
|
|
67
|
+
from,
|
|
68
|
+
to: args.to,
|
|
69
|
+
text: args.text,
|
|
70
|
+
...(args.webhookUrl ? { webhook_url: args.webhookUrl } : {}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
await ctx.runMutation(internal.telnyx.recordMessage, {
|
|
74
|
+
owner: args.owner,
|
|
75
|
+
providerMessageId: result.id,
|
|
76
|
+
to: args.to,
|
|
77
|
+
text: args.text,
|
|
78
|
+
status: result.status,
|
|
79
|
+
createdAt: Date.now(),
|
|
80
|
+
});
|
|
81
|
+
return result;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify an Ed25519-signed Telnyx webhook against the exact raw body and store
|
|
87
|
+
* the event once by Telnyx event id. Returns `null` for an invalid signature,
|
|
88
|
+
* a stale timestamp, or an unparseable body, so the host can answer 401 and
|
|
89
|
+
* let Telnyx retry.
|
|
90
|
+
*/
|
|
91
|
+
export const verifyWebhook = action({
|
|
92
|
+
args: {
|
|
93
|
+
rawBody: v.string(),
|
|
94
|
+
headers: v.record(v.string(), v.string()),
|
|
95
|
+
},
|
|
96
|
+
returns: webhookEvent,
|
|
97
|
+
handler: async (ctx, args): Promise<TelnyxWebhookEvent | null> => {
|
|
98
|
+
const event = verifyTelnyxWebhook({
|
|
99
|
+
rawBody: args.rawBody,
|
|
100
|
+
headers: args.headers,
|
|
101
|
+
publicKey: env.TELNYX_PUBLIC_KEY,
|
|
102
|
+
});
|
|
103
|
+
if (!event) return null;
|
|
104
|
+
await ctx.runMutation(internal.telnyx.recordWebhook, {
|
|
105
|
+
eventId: event.id,
|
|
106
|
+
eventType: event.event_type,
|
|
107
|
+
payload: event,
|
|
108
|
+
occurredAt: event.occurred_at,
|
|
109
|
+
receivedAt: Date.now(),
|
|
110
|
+
});
|
|
111
|
+
return event;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Insert a sent message. Idempotent on `providerMessageId`. Internal: not part of the host-facing API. */
|
|
116
|
+
export const recordMessage = internalMutation({
|
|
117
|
+
args: {
|
|
118
|
+
owner: v.string(),
|
|
119
|
+
providerMessageId: v.string(),
|
|
120
|
+
to: v.string(),
|
|
121
|
+
text: v.string(),
|
|
122
|
+
status: v.string(),
|
|
123
|
+
createdAt: v.number(),
|
|
124
|
+
},
|
|
125
|
+
returns: v.null(),
|
|
126
|
+
handler: async (ctx, args) => {
|
|
127
|
+
const existing = await ctx.db
|
|
128
|
+
.query("messages")
|
|
129
|
+
.withIndex("by_provider_message", (q) => q.eq("providerMessageId", args.providerMessageId))
|
|
130
|
+
.first();
|
|
131
|
+
if (existing) return null;
|
|
132
|
+
await ctx.db.insert("messages", args);
|
|
133
|
+
return null;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** Insert a verified webhook event. Idempotent on `eventId`. Internal: not part of the host-facing API. */
|
|
138
|
+
export const recordWebhook = internalMutation({
|
|
139
|
+
args: {
|
|
140
|
+
eventId: v.string(),
|
|
141
|
+
eventType: v.string(),
|
|
142
|
+
payload: v.any(),
|
|
143
|
+
occurredAt: v.optional(v.string()),
|
|
144
|
+
receivedAt: v.number(),
|
|
145
|
+
},
|
|
146
|
+
returns: v.null(),
|
|
147
|
+
handler: async (ctx, args) => {
|
|
148
|
+
const existing = await ctx.db
|
|
149
|
+
.query("webhookEvents")
|
|
150
|
+
.withIndex("by_event", (q) => q.eq("eventId", args.eventId))
|
|
151
|
+
.first();
|
|
152
|
+
if (existing) return null;
|
|
153
|
+
await ctx.db.insert("webhookEvents", args);
|
|
154
|
+
return null;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/** List an owner's most recent messages, newest first. */
|
|
159
|
+
export const listMessages = query({
|
|
160
|
+
args: { owner: v.string(), limit: v.optional(v.number()) },
|
|
161
|
+
returns: v.array(messageRow),
|
|
162
|
+
handler: async (ctx, args) => {
|
|
163
|
+
return await ctx.db
|
|
164
|
+
.query("messages")
|
|
165
|
+
.withIndex("by_owner", (q) => q.eq("owner", args.owner))
|
|
166
|
+
.order("desc")
|
|
167
|
+
.take(args.limit ?? 50);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** List the most recently received verified webhook events, newest first. */
|
|
172
|
+
export const listWebhookEvents = query({
|
|
173
|
+
args: { limit: v.optional(v.number()) },
|
|
174
|
+
returns: v.array(webhookEventRow),
|
|
175
|
+
handler: async (ctx, args) => {
|
|
176
|
+
return await ctx.db
|
|
177
|
+
.query("webhookEvents")
|
|
178
|
+
.withIndex("by_received", (q) => q.gte("receivedAt", 0))
|
|
179
|
+
.order("desc")
|
|
180
|
+
.take(args.limit ?? 50);
|
|
181
|
+
},
|
|
182
|
+
});
|
package/test.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TestConvex } from "convex-test";
|
|
2
|
+
import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
3
|
+
import * as telnyxFunctions from "./telnyx.js";
|
|
4
|
+
import * as generatedApi from "./_generated/api.js";
|
|
5
|
+
import * as generatedServer from "./_generated/server.js";
|
|
6
|
+
import schema from "./schema.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The component's function modules, for `convex-test`.
|
|
10
|
+
*
|
|
11
|
+
* Declared statically rather than with `import.meta.glob` so the Convex
|
|
12
|
+
* analyzer can bundle this directory: `import.meta` is not available in the
|
|
13
|
+
* Convex runtime. The `_generated` entries are required by `convex-test` to
|
|
14
|
+
* locate the modules root.
|
|
15
|
+
*/
|
|
16
|
+
export const modules: Record<string, () => Promise<unknown>> = {
|
|
17
|
+
"./telnyx.ts": async () => telnyxFunctions,
|
|
18
|
+
"./_generated/api.ts": async () => generatedApi,
|
|
19
|
+
"./_generated/server.ts": async () => generatedServer,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register the component with the test convex instance.
|
|
24
|
+
*
|
|
25
|
+
* @param t The test convex instance, e.g. from calling `convexTest`.
|
|
26
|
+
* @param name The name of the component, as registered in `convex.config.ts`.
|
|
27
|
+
*/
|
|
28
|
+
export function register(
|
|
29
|
+
t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
|
|
30
|
+
name: string = "telnyx",
|
|
31
|
+
) {
|
|
32
|
+
t.registerComponent(name, schema, modules);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default { register, schema, modules };
|