@hera-al/server 1.6.7 → 1.6.11

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.
Files changed (53) hide show
  1. package/dist/agent/agent-service.d.ts +1 -0
  2. package/dist/agent/agent-service.js +1 -1
  3. package/dist/agent/session-agent.d.ts +4 -2
  4. package/dist/agent/session-agent.js +1 -1
  5. package/dist/agent/session-db.d.ts +2 -0
  6. package/dist/agent/session-db.js +1 -1
  7. package/dist/commands/command.d.ts +7 -0
  8. package/dist/commands/help.d.ts +1 -1
  9. package/dist/commands/help.js +1 -1
  10. package/dist/commands/models.d.ts +1 -1
  11. package/dist/commands/models.js +1 -1
  12. package/dist/config.d.ts +170 -1
  13. package/dist/config.js +1 -1
  14. package/dist/cron/cron-service.d.ts +36 -2
  15. package/dist/cron/cron-service.js +1 -1
  16. package/dist/cron/types.d.ts +6 -0
  17. package/dist/gateway/bridge.d.ts +1 -1
  18. package/dist/gateway/channel-manager.d.ts +1 -1
  19. package/dist/gateway/channel-manager.js +1 -1
  20. package/dist/gateway/channels/telegram/config-types.d.ts +93 -0
  21. package/dist/gateway/channels/telegram/config-types.js +1 -0
  22. package/dist/gateway/channels/telegram/edit-delete.d.ts +73 -0
  23. package/dist/gateway/channels/telegram/edit-delete.js +1 -0
  24. package/dist/gateway/channels/telegram/error-handling.d.ts +63 -0
  25. package/dist/gateway/channels/telegram/error-handling.js +1 -0
  26. package/dist/gateway/channels/{telegram.d.ts → telegram/index.d.ts} +23 -11
  27. package/dist/gateway/channels/telegram/index.js +1 -0
  28. package/dist/gateway/channels/telegram/inline-buttons.d.ts +60 -0
  29. package/dist/gateway/channels/telegram/inline-buttons.js +1 -0
  30. package/dist/gateway/channels/telegram/polls.d.ts +50 -0
  31. package/dist/gateway/channels/telegram/polls.js +1 -0
  32. package/dist/gateway/channels/telegram/reactions.d.ts +56 -0
  33. package/dist/gateway/channels/telegram/reactions.js +1 -0
  34. package/dist/gateway/channels/telegram/retry-policy.d.ts +28 -0
  35. package/dist/gateway/channels/telegram/retry-policy.js +1 -0
  36. package/dist/gateway/channels/telegram/send.d.ts +55 -0
  37. package/dist/gateway/channels/telegram/send.js +1 -0
  38. package/dist/gateway/channels/telegram/stickers.d.ts +96 -0
  39. package/dist/gateway/channels/telegram/stickers.js +1 -0
  40. package/dist/gateway/channels/telegram/thread-support.d.ts +99 -0
  41. package/dist/gateway/channels/telegram/thread-support.js +1 -0
  42. package/dist/gateway/channels/telegram/utils.d.ts +69 -0
  43. package/dist/gateway/channels/telegram/utils.js +1 -0
  44. package/dist/gateway/channels/webchat.d.ts +1 -1
  45. package/dist/gateway/channels/webchat.js +1 -1
  46. package/dist/server.js +1 -1
  47. package/dist/tools/cron-tools.js +1 -1
  48. package/dist/tools/message-tools.js +1 -1
  49. package/dist/tools/telegram-actions-tools.d.ts +13 -0
  50. package/dist/tools/telegram-actions-tools.js +1 -0
  51. package/installationPkg/config.example.yaml +45 -0
  52. package/package.json +1 -1
  53. package/dist/gateway/channels/telegram.js +0 -1
@@ -8,6 +8,10 @@ export type CronServiceOpts = {
8
8
  enabled: boolean;
9
9
  defaultTimezone?: string;
10
10
  onExecute: (job: CronJob) => Promise<CronExecuteResult>;
11
+ /** Optional: prune stale cron sessions from SessionDB. */
12
+ sessionReaper?: {
13
+ pruneStaleSessions: (maxAgeMs: number) => number;
14
+ };
11
15
  };
12
16
  export type CronStatusSummary = {
13
17
  enabled: boolean;
@@ -20,22 +24,46 @@ export declare class CronService {
20
24
  private timer;
21
25
  private running;
22
26
  private op;
27
+ private lastReaperSweepAtMs;
23
28
  private readonly storePath;
24
29
  private readonly enabled;
25
30
  private readonly defaultTimezone;
26
31
  private readonly onExecute;
32
+ private readonly sessionReaper?;
27
33
  constructor(opts: CronServiceOpts);
28
34
  private locked;
29
35
  private ensureLoaded;
30
36
  private persist;
37
+ private computeStaggeredCronNextRunAtMs;
31
38
  private computeJobNextRunAtMs;
39
+ /**
40
+ * Safe wrapper for schedule computation. Returns `true` if value changed.
41
+ * On error, increments scheduleErrorCount and auto-disables after MAX_SCHEDULE_ERRORS.
42
+ */
43
+ private safeRecomputeJobNextRunAtMs;
44
+ /**
45
+ * Full recompute: recomputes nextRunAtMs for all enabled jobs.
46
+ * Use only at start() when we need a clean baseline.
47
+ */
32
48
  private recomputeNextRuns;
49
+ /**
50
+ * Maintenance-only recompute: handles disabled jobs and stuck markers,
51
+ * but does NOT recompute nextRunAtMs for enabled jobs that already have a value.
52
+ * Prevents silently advancing past-due nextRunAtMs without execution.
53
+ * (OpenClaw #13992, #17852)
54
+ */
55
+ private recomputeNextRunsForMaintenance;
33
56
  private nextWakeAtMs;
34
57
  private armTimer;
35
58
  private stopTimer;
36
59
  private onTimer;
37
- private runDueJobs;
38
- private executeJob;
60
+ private findDueJobs;
61
+ /** Resolve the effective timeout for a job (per-job override or global default). */
62
+ private resolveJobTimeoutMs;
63
+ /** Execute onExecute with a safety-net timeout (OpenClaw DEFAULT_JOB_TIMEOUT_MS). */
64
+ private executeWithTimeout;
65
+ private applyJobResult;
66
+ private sweepStaleSessions;
39
67
  start(): Promise<void>;
40
68
  stop(): void;
41
69
  status(): Promise<CronStatusSummary>;
@@ -53,5 +81,11 @@ export declare class CronService {
53
81
  ran: boolean;
54
82
  reason?: string;
55
83
  }>;
84
+ /**
85
+ * After restart, find and execute jobs that were due during downtime.
86
+ * Skips one-shot ("at") jobs that already ran at least once.
87
+ * (OpenClaw runMissedJobs)
88
+ */
89
+ private runMissedJobs;
56
90
  }
57
91
  //# sourceMappingURL=cron-service.d.ts.map
@@ -1 +1 @@
1
- import t from"node:crypto";import{computeNextRunAtMs as e}from"./schedule.js";import{loadCronStore as s,saveCronStore as n}from"./store.js";import{createLogger as a}from"../utils/logger.js";const i=a("Cron"),o=t=>t.then(()=>{},()=>{});export class CronService{store=null;timer=null;running=!1;op=Promise.resolve();storePath;enabled;defaultTimezone;onExecute;constructor(t){this.storePath=t.storePath,this.enabled=t.enabled,this.defaultTimezone=t.defaultTimezone||"",this.onExecute=t.onExecute}async locked(t){const e=o(this.op).then(t),s=o(e);return this.op=s,await e}async ensureLoaded(t){this.store&&!t?.forceReload||(this.store=await s(this.storePath))}async persist(){this.store&&await n(this.storePath,this.store)}computeJobNextRunAtMs(t,s){if(t.enabled){if("at"===t.schedule.kind){if("ok"===t.state.lastStatus&&t.state.lastRunAtMs)return;const e=new Date(t.schedule.at).getTime();return Number.isFinite(e)?e:void 0}return e(t.schedule,s,this.defaultTimezone)}}recomputeNextRuns(){if(!this.store)return;const t=Date.now();for(const e of this.store.jobs){if(e.state||(e.state={}),!e.enabled){e.state.nextRunAtMs=void 0,e.state.runningAtMs=void 0;continue}const s=e.state.runningAtMs;"number"==typeof s&&t-s>72e5&&(i.warn(`Clearing stuck running marker for job ${e.id}`),e.state.runningAtMs=void 0),e.state.nextRunAtMs=this.computeJobNextRunAtMs(e,t)}}nextWakeAtMs(){const t=(this.store?.jobs??[]).filter(t=>t.enabled&&"number"==typeof t.state.nextRunAtMs);if(0!==t.length)return t.reduce((t,e)=>Math.min(t,e.state.nextRunAtMs),t[0].state.nextRunAtMs)}armTimer(){if(this.timer&&clearTimeout(this.timer),this.timer=null,!this.enabled)return;const t=this.nextWakeAtMs();if(!t)return;const e=Math.max(t-Date.now(),0),s=Math.min(e,2147483647);this.timer=setTimeout(()=>{this.onTimer().catch(t=>{i.error(`Timer tick failed: ${t}`)})},s)}stopTimer(){this.timer&&clearTimeout(this.timer),this.timer=null}async onTimer(){if(!this.running){this.running=!0;try{await this.locked(async()=>{await this.ensureLoaded({forceReload:!0}),await this.runDueJobs(),this.recomputeNextRuns(),await this.persist()})}finally{this.running=!1,this.armTimer()}}}async runDueJobs(){if(!this.store)return;const t=Date.now(),e=this.store.jobs.filter(e=>{if(!e.enabled)return!1;if("number"==typeof e.state.runningAtMs)return!1;const s=e.state.nextRunAtMs;return"number"==typeof s&&t>=s});for(const t of e)await this.executeJob(t,{forced:!1})}async executeJob(t,e){const s=Date.now();t.state.runningAtMs=s,t.state.lastError=void 0;let n=!1;const a=async(e,a)=>{const i=Date.now();t.state.runningAtMs=void 0,t.state.lastRunAtMs=s,t.state.lastStatus=e,t.state.lastDurationMs=Math.max(0,i-s),t.state.lastError=a;const o="at"===t.schedule.kind&&"ok"===e&&!0===t.deleteAfterRun;o||("at"===t.schedule.kind&&"ok"===e?(t.enabled=!1,t.state.nextRunAtMs=void 0):t.enabled?t.state.nextRunAtMs=this.computeJobNextRunAtMs(t,i):t.state.nextRunAtMs=void 0),o&&this.store&&(this.store.jobs=this.store.jobs.filter(e=>e.id!==t.id),n=!0)};try{i.info(`Executing job "${t.name}" (${t.id})`);const e=(await this.onExecute(t)).delivered?"ok (delivered)":"ok (suppressed)";i.info(`Job "${t.name}" finished: ${e}`),await a("ok")}catch(e){i.error(`Job "${t.name}" failed: ${e}`),await a("error",String(e))}finally{t.updatedAtMs=Date.now(),e.forced||!t.enabled||n||(t.state.nextRunAtMs=this.computeJobNextRunAtMs(t,Date.now()))}}async start(){await this.locked(async()=>{this.enabled?(await this.ensureLoaded(),this.recomputeNextRuns(),await this.persist(),this.armTimer(),i.info(`Cron started (${this.store?.jobs.length??0} jobs, next wake: ${this.nextWakeAtMs()??"none"})`)):i.info("Cron disabled")})}stop(){this.stopTimer()}async status(){return await this.locked(async()=>(await this.ensureLoaded(),{enabled:this.enabled,storePath:this.storePath,jobs:this.store?.jobs.length??0,nextWakeAtMs:this.enabled?this.nextWakeAtMs()??null:null}))}async list(t){return await this.locked(async()=>{await this.ensureLoaded();const e=!0===t?.includeDisabled;return[...(this.store?.jobs??[]).filter(t=>e||t.enabled)].sort((t,e)=>(t.state.nextRunAtMs??0)-(e.state.nextRunAtMs??0))})}async add(e){return await this.locked(async()=>{await this.ensureLoaded();const s=Date.now(),n=t.randomUUID(),a="boolean"==typeof e.deleteAfterRun?e.deleteAfterRun:"at"===e.schedule.kind||void 0,o="boolean"!=typeof e.enabled||e.enabled,r="boolean"!=typeof e.isolated||e.isolated,d={id:n,name:e.name.trim()||"Untitled",description:e.description?.trim()||void 0,enabled:o,isolated:r,deleteAfterRun:a,suppressToken:e.suppressToken,createdAtMs:s,updatedAtMs:s,schedule:e.schedule,channel:e.channel,chatId:e.chatId,message:e.message,state:{...e.state}};return d.state.nextRunAtMs=this.computeJobNextRunAtMs(d,s),this.store?.jobs.push(d),await this.persist(),this.armTimer(),i.info(`Job added: "${d.name}" (${d.id}), next run: ${d.state.nextRunAtMs??"none"}`),d})}async update(t,e){return await this.locked(async()=>{await this.ensureLoaded();const s=this.store?.jobs.find(e=>e.id===t);if(!s)throw new Error(`Unknown cron job id: ${t}`);const n=Date.now();return"name"in e&&"string"==typeof e.name&&(s.name=e.name.trim()||s.name),"description"in e&&(s.description=e.description?.trim()||void 0),"boolean"==typeof e.enabled&&(s.enabled=e.enabled),"boolean"==typeof e.isolated&&(s.isolated=e.isolated),"boolean"==typeof e.deleteAfterRun&&(s.deleteAfterRun=e.deleteAfterRun),"boolean"==typeof e.suppressToken&&(s.suppressToken=e.suppressToken),e.schedule&&(s.schedule=e.schedule),"string"==typeof e.channel&&(s.channel=e.channel),"string"==typeof e.chatId&&(s.chatId=e.chatId),"string"==typeof e.message&&(s.message=e.message),e.state&&(s.state={...s.state,...e.state}),s.updatedAtMs=n,s.enabled?s.state.nextRunAtMs=this.computeJobNextRunAtMs(s,n):(s.state.nextRunAtMs=void 0,s.state.runningAtMs=void 0),await this.persist(),this.armTimer(),s})}async remove(t){return await this.locked(async()=>{if(await this.ensureLoaded(),!this.store)return{ok:!1,removed:!1};const e=this.store.jobs.length;this.store.jobs=this.store.jobs.filter(e=>e.id!==t);const s=this.store.jobs.length!==e;return await this.persist(),this.armTimer(),s&&i.info(`Job removed: ${t}`),{ok:!0,removed:s}})}async run(t,e){return await this.locked(async()=>{await this.ensureLoaded();const s=this.store?.jobs.find(e=>e.id===t);if(!s)throw new Error(`Unknown cron job id: ${t}`);const n=Date.now(),a="force"===e;return a||s.enabled&&"number"==typeof s.state.nextRunAtMs&&n>=s.state.nextRunAtMs?(await this.executeJob(s,{forced:a}),await this.persist(),this.armTimer(),{ok:!0,ran:!0}):{ok:!0,ran:!1,reason:"not-due"}})}}
1
+ import e from"node:crypto";import{computeNextRunAtMs as t}from"./schedule.js";import{loadCronStore as s,saveCronStore as n}from"./store.js";import{createLogger as r}from"../utils/logger.js";const o=r("Cron"),i=72e5,a=[3e4,6e4,3e5,9e5,36e5];function u(e){return"number"==typeof e.staggerMs&&Number.isFinite(e.staggerMs)?Math.max(0,Math.floor(e.staggerMs)):function(e){const t=e.trim().split(/\s+/).filter(Boolean);if(5===t.length){const[e,s]=t;return"0"===e&&s.includes("*")}if(6===t.length){const[e,s,n]=t;return"0"===e&&"0"===s&&n.includes("*")}return!1}(e.expr)?3e5:0}const d=e=>e.then(()=>{},()=>{});export class CronService{store=null;timer=null;running=!1;op=Promise.resolve();lastReaperSweepAtMs=0;storePath;enabled;defaultTimezone;onExecute;sessionReaper;constructor(e){this.storePath=e.storePath,this.enabled=e.enabled,this.defaultTimezone=e.defaultTimezone||"",this.onExecute=e.onExecute,this.sessionReaper=e.sessionReaper}async locked(e){const t=d(this.op).then(e),s=d(t);return this.op=s,await t}async ensureLoaded(e){this.store&&!e?.forceReload||(this.store=await s(this.storePath))}async persist(){this.store&&await n(this.storePath,this.store)}computeStaggeredCronNextRunAtMs(s,n){if("cron"!==s.schedule.kind)return t(s.schedule,n,this.defaultTimezone);const r=u(s.schedule),o=function(t,s){return s<=1?0:e.createHash("sha256").update(t).digest().readUInt32BE(0)%s}(s.id,r);if(o<=0)return t(s.schedule,n,this.defaultTimezone);let i=Math.max(0,n-o);for(let e=0;e<4;e+=1){const e=t(s.schedule,i,this.defaultTimezone);if(void 0===e)return;const r=e+o;if(r>n)return r;i=Math.max(i+1,e+1e3)}}computeJobNextRunAtMs(e,s){if(e.enabled){if("at"===e.schedule.kind){if("ok"===e.state.lastStatus&&e.state.lastRunAtMs)return;const t=new Date(e.schedule.at).getTime();return Number.isFinite(t)?t:void 0}if("cron"===e.schedule.kind){const t=this.computeStaggeredCronNextRunAtMs(e,s);if(void 0===t){const t=1e3*Math.floor(s/1e3)+1e3;return this.computeStaggeredCronNextRunAtMs(e,t)}return t}return t(e.schedule,s,this.defaultTimezone)}}safeRecomputeJobNextRunAtMs(e,t){let s=!1;try{const n=this.computeJobNextRunAtMs(e,t);e.state.nextRunAtMs!==n&&(e.state.nextRunAtMs=n,s=!0),e.state.scheduleErrorCount&&(e.state.scheduleErrorCount=void 0,s=!0)}catch(t){const n=(e.state.scheduleErrorCount??0)+1;e.state.scheduleErrorCount=n,e.state.nextRunAtMs=void 0,e.state.lastError=`schedule error: ${String(t)}`,s=!0,n>=3?(e.enabled=!1,o.error(`Auto-disabled job "${e.name}" after ${n} schedule errors: ${t}`)):o.warn(`Schedule compute error for "${e.name}" (${n}/3): ${t}`)}return s}recomputeNextRuns(){if(!this.store)return;const e=Date.now();for(const t of this.store.jobs){if(t.state||(t.state={}),!t.enabled){t.state.nextRunAtMs=void 0,t.state.runningAtMs=void 0;continue}const s=t.state.runningAtMs;"number"==typeof s&&e-s>i&&(o.warn(`Clearing stuck running marker for job ${t.id}`),t.state.runningAtMs=void 0),this.safeRecomputeJobNextRunAtMs(t,e)}}recomputeNextRunsForMaintenance(){if(!this.store)return!1;const e=Date.now();let t=!1;for(const s of this.store.jobs){if(s.state||(s.state={},t=!0),!s.enabled){void 0!==s.state.nextRunAtMs&&(s.state.nextRunAtMs=void 0,t=!0),void 0!==s.state.runningAtMs&&(s.state.runningAtMs=void 0,t=!0);continue}const n=s.state.runningAtMs;"number"==typeof n&&e-n>i&&(o.warn(`Clearing stuck running marker for job ${s.id}`),s.state.runningAtMs=void 0,t=!0),void 0===s.state.nextRunAtMs&&this.safeRecomputeJobNextRunAtMs(s,e)&&(t=!0)}return t}nextWakeAtMs(){const e=(this.store?.jobs??[]).filter(e=>e.enabled&&"number"==typeof e.state.nextRunAtMs);if(0!==e.length)return e.reduce((e,t)=>Math.min(e,t.state.nextRunAtMs),e[0].state.nextRunAtMs)}armTimer(){if(this.timer&&clearTimeout(this.timer),this.timer=null,!this.enabled)return;const e=this.nextWakeAtMs();if(!e)return;const t=Math.max(e-Date.now(),0),s=Math.min(t,6e4);this.timer=setTimeout(()=>{this.onTimer().catch(e=>{o.error(`Timer tick failed: ${e}`)})},s)}stopTimer(){this.timer&&clearTimeout(this.timer),this.timer=null}async onTimer(){if(this.running)return this.timer&&clearTimeout(this.timer),void(this.timer=setTimeout(()=>{this.onTimer().catch(e=>{o.error(`Timer tick failed: ${e}`)})},6e4));this.running=!0;try{const e=await this.locked(async()=>{await this.ensureLoaded({forceReload:!0});const e=this.findDueJobs();if(0===e.length){return this.recomputeNextRunsForMaintenance()&&await this.persist(),[]}const t=Date.now();for(const s of e)s.state.runningAtMs=t,s.state.lastError=void 0;return await this.persist(),e}),t=[];for(const s of e){const e=Date.now();try{o.info(`Executing job "${s.name}" (${s.id})`);const n=await this.executeWithTimeout(s),r=n.delivered?"ok (delivered)":"ok (suppressed)";o.info(`Job "${s.name}" finished: ${r}`),t.push({job:s,status:"ok",delivered:n.delivered,startedAt:e,endedAt:Date.now()})}catch(n){o.error(`Job "${s.name}" failed: ${n}`),t.push({job:s,status:"error",error:String(n),delivered:!1,startedAt:e,endedAt:Date.now()})}}t.length>0&&await this.locked(async()=>{await this.ensureLoaded({forceReload:!0});for(const e of t){const t=this.store?.jobs.find(t=>t.id===e.job.id);t&&this.applyJobResult(t,e)}this.recomputeNextRunsForMaintenance(),await this.persist()}),this.sweepStaleSessions()}finally{this.running=!1,this.armTimer()}}findDueJobs(){if(!this.store)return[];const e=Date.now();return this.store.jobs.filter(t=>{if(!t.enabled)return!1;if("number"==typeof t.state.runningAtMs)return!1;const s=t.state.nextRunAtMs;return"number"==typeof s&&e>=s})}resolveJobTimeoutMs(e){if("number"==typeof e.timeoutSeconds){if(e.timeoutSeconds<=0)return;return Math.floor(1e3*e.timeoutSeconds)}return 6e5}async executeWithTimeout(e){const t=this.resolveJobTimeoutMs(e);if(void 0===t)return this.onExecute(e);let s;try{return await Promise.race([this.onExecute(e),new Promise((n,r)=>{s=setTimeout(()=>r(new Error(`cron: job "${e.name}" timed out after ${t/1e3}s`)),t)})])}finally{s&&clearTimeout(s)}}applyJobResult(e,t){e.state.runningAtMs=void 0,e.state.lastRunAtMs=t.startedAt,e.state.lastStatus=t.status,e.state.lastDurationMs=Math.max(0,t.endedAt-t.startedAt),e.state.lastError=t.error,e.updatedAtMs=t.endedAt,"error"===t.status?e.state.consecutiveErrors=(e.state.consecutiveErrors??0)+1:e.state.consecutiveErrors=0;const s="at"===e.schedule.kind&&"ok"===t.status&&!0===e.deleteAfterRun;if(!s)if("at"===e.schedule.kind)e.enabled=!1,e.state.nextRunAtMs=void 0;else if("error"===t.status&&e.enabled){const s=e.state.consecutiveErrors??1,n=Math.min(s-1,a.length-1),r=a[Math.max(0,n)],i=this.computeJobNextRunAtMs(e,t.endedAt),u=t.endedAt+r;e.state.nextRunAtMs=void 0!==i?Math.max(i,u):u,o.info(`Job "${e.name}" error backoff: ${s} consecutive errors, next retry in ${r/1e3}s`)}else if(e.enabled){const s=this.computeJobNextRunAtMs(e,t.endedAt);if("cron"===e.schedule.kind){const n=t.endedAt+2e3;e.state.nextRunAtMs=void 0!==s?Math.max(s,n):n}else e.state.nextRunAtMs=s}else e.state.nextRunAtMs=void 0;s&&this.store&&(this.store.jobs=this.store.jobs.filter(t=>t.id!==e.id))}sweepStaleSessions(){if(!this.sessionReaper)return;const e=Date.now();if(!(e-this.lastReaperSweepAtMs<3e5)){this.lastReaperSweepAtMs=e;try{const e=this.sessionReaper.pruneStaleSessions(864e5);e>0&&o.info(`Session reaper: pruned ${e} stale cron session(s)`)}catch(e){o.warn(`Session reaper sweep failed: ${e}`)}}}async start(){await this.locked(async()=>{this.enabled?(await this.ensureLoaded(),this.recomputeNextRuns(),await this.persist(),this.armTimer(),o.info(`Cron started (${this.store?.jobs.length??0} jobs, next wake: ${this.nextWakeAtMs()??"none"})`)):o.info("Cron disabled")}),this.enabled&&await this.runMissedJobs()}stop(){this.stopTimer()}async status(){return this.store||await this.locked(async()=>{await this.ensureLoaded()}),{enabled:this.enabled,storePath:this.storePath,jobs:this.store?.jobs.length??0,nextWakeAtMs:this.enabled?this.nextWakeAtMs()??null:null}}async list(e){this.store||await this.locked(async()=>{await this.ensureLoaded()});const t=!0===e?.includeDisabled;return[...(this.store?.jobs??[]).filter(e=>t||e.enabled)].sort((e,t)=>(e.state.nextRunAtMs??0)-(t.state.nextRunAtMs??0))}async add(t){return await this.locked(async()=>{await this.ensureLoaded();const s=Date.now(),n=e.randomUUID(),r="boolean"==typeof t.deleteAfterRun?t.deleteAfterRun:"at"===t.schedule.kind||void 0,i="boolean"!=typeof t.enabled||t.enabled,a="boolean"!=typeof t.isolated||t.isolated,u={id:n,name:t.name.trim()||"Untitled",description:t.description?.trim()||void 0,enabled:i,isolated:a,deleteAfterRun:r,suppressToken:t.suppressToken,timeoutSeconds:t.timeoutSeconds,createdAtMs:s,updatedAtMs:s,schedule:t.schedule,channel:t.channel,chatId:t.chatId,message:t.message,state:{...t.state}};return u.state.nextRunAtMs=this.computeJobNextRunAtMs(u,s),this.store?.jobs.push(u),await this.persist(),this.armTimer(),o.info(`Job added: "${u.name}" (${u.id}), next run: ${u.state.nextRunAtMs??"none"}`),u})}async update(e,t){return await this.locked(async()=>{await this.ensureLoaded();const s=this.store?.jobs.find(t=>t.id===e);if(!s)throw new Error(`Unknown cron job id: ${e}`);const n=Date.now();return"name"in t&&"string"==typeof t.name&&(s.name=t.name.trim()||s.name),"description"in t&&(s.description=t.description?.trim()||void 0),"boolean"==typeof t.enabled&&(s.enabled=t.enabled),"boolean"==typeof t.isolated&&(s.isolated=t.isolated),"boolean"==typeof t.deleteAfterRun&&(s.deleteAfterRun=t.deleteAfterRun),"boolean"==typeof t.suppressToken&&(s.suppressToken=t.suppressToken),"number"==typeof t.timeoutSeconds&&(s.timeoutSeconds=t.timeoutSeconds),t.schedule&&(s.schedule=t.schedule),"string"==typeof t.channel&&(s.channel=t.channel),"string"==typeof t.chatId&&(s.chatId=t.chatId),"string"==typeof t.message&&(s.message=t.message),t.state&&(s.state={...s.state,...t.state}),s.updatedAtMs=n,s.enabled?s.state.nextRunAtMs=this.computeJobNextRunAtMs(s,n):(s.state.nextRunAtMs=void 0,s.state.runningAtMs=void 0),await this.persist(),this.armTimer(),s})}async remove(e){return await this.locked(async()=>{if(await this.ensureLoaded(),!this.store)return{ok:!1,removed:!1};const t=this.store.jobs.length;this.store.jobs=this.store.jobs.filter(t=>t.id!==e);const s=this.store.jobs.length!==t;return await this.persist(),this.armTimer(),s&&o.info(`Job removed: ${e}`),{ok:!0,removed:s}})}async run(e,t){const s=await this.locked(async()=>{await this.ensureLoaded();const s=this.store?.jobs.find(t=>t.id===e);if(!s)throw new Error(`Unknown cron job id: ${e}`);const n=Date.now();return"force"===t||s.enabled&&"number"==typeof s.state.nextRunAtMs&&n>=s.state.nextRunAtMs?(s.state.runningAtMs=n,s.state.lastError=void 0,await this.persist(),s):null});if(!s)return{ok:!0,ran:!1,reason:"not-due"};const n=Date.now();let r,i="ok";try{o.info(`Executing job "${s.name}" (${s.id}) [manual run]`);const e=(await this.executeWithTimeout(s)).delivered?"ok (delivered)":"ok (suppressed)";o.info(`Job "${s.name}" finished: ${e}`)}catch(e){o.error(`Job "${s.name}" failed: ${e}`),i="error",r=String(e)}return await this.locked(async()=>{await this.ensureLoaded({forceReload:!0});const e=this.store?.jobs.find(e=>e.id===s.id);e&&this.applyJobResult(e,{status:i,error:r,startedAt:n,endedAt:Date.now()}),this.recomputeNextRunsForMaintenance(),await this.persist(),this.armTimer()}),{ok:!0,ran:!0}}async runMissedJobs(){const e=await this.locked(async()=>{if(await this.ensureLoaded(),!this.store)return[];const e=Date.now();return this.store.jobs.filter(t=>{if(!t.enabled)return!1;if("number"==typeof t.state.runningAtMs)return!1;if("at"===t.schedule.kind&&t.state.lastStatus)return!1;const s=t.state.nextRunAtMs;return"number"==typeof s&&e>=s})});if(0!==e.length){o.info(`Running ${e.length} missed job(s) after restart: ${e.map(e=>e.name).join(", ")}`);for(const t of e){const e=Date.now();t.state.runningAtMs=e,t.state.lastError=void 0;let s,n="ok";try{o.info(`Executing missed job "${t.name}" (${t.id})`);const e=(await this.executeWithTimeout(t)).delivered?"ok (delivered)":"ok (suppressed)";o.info(`Missed job "${t.name}" finished: ${e}`)}catch(e){o.error(`Missed job "${t.name}" failed: ${e}`),n="error",s=String(e)}await this.locked(async()=>{await this.ensureLoaded({forceReload:!0});const r=this.store?.jobs.find(e=>e.id===t.id);r&&this.applyJobResult(r,{status:n,error:s,startedAt:e,endedAt:Date.now()}),this.recomputeNextRunsForMaintenance(),await this.persist(),this.armTimer()})}}}}
@@ -9,6 +9,7 @@ export type CronSchedule = {
9
9
  kind: "cron";
10
10
  expr: string;
11
11
  tz?: string;
12
+ staggerMs?: number;
12
13
  };
13
14
  export type CronJobState = {
14
15
  nextRunAtMs?: number;
@@ -17,6 +18,9 @@ export type CronJobState = {
17
18
  lastStatus?: "ok" | "error" | "skipped";
18
19
  lastError?: string;
19
20
  lastDurationMs?: number;
21
+ consecutiveErrors?: number;
22
+ /** How many times schedule computation failed in a row (auto-disable after 3). */
23
+ scheduleErrorCount?: number;
20
24
  };
21
25
  export type CronJob = {
22
26
  id: string;
@@ -26,6 +30,8 @@ export type CronJob = {
26
30
  isolated: boolean;
27
31
  deleteAfterRun?: boolean;
28
32
  suppressToken: boolean;
33
+ /** Per-job execution timeout in seconds. 0 = no timeout. Defaults to DEFAULT_JOB_TIMEOUT_MS. */
34
+ timeoutSeconds?: number;
29
35
  createdAtMs: number;
30
36
  updatedAtMs: number;
31
37
  schedule: CronSchedule;
@@ -28,7 +28,7 @@ export interface ChannelAdapter {
28
28
  start(onMessage: MessageHandler): Promise<void>;
29
29
  sendText(chatId: string, text: string): Promise<void>;
30
30
  sendAudio?(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
31
- sendButtons?(chatId: string, text: string, buttons: InlineButton[]): Promise<void>;
31
+ sendButtons?(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
32
32
  setTyping?(chatId: string): Promise<void>;
33
33
  clearTyping?(chatId: string): Promise<void>;
34
34
  /** Cooperative typing release: decrements refcount instead of force-clearing. */
@@ -11,7 +11,7 @@ export declare class ChannelManager {
11
11
  startAll(): Promise<void>;
12
12
  sendToChannel(channelName: string, chatId: string, text: string): Promise<void>;
13
13
  sendAudio(channelName: string, chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
14
- sendButtons(channelName: string, chatId: string, text: string, buttons: InlineButton[]): Promise<void>;
14
+ sendButtons(channelName: string, chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
15
15
  setTyping(channelName: string, chatId: string): Promise<void>;
16
16
  clearTyping(channelName: string, chatId: string): Promise<void>;
17
17
  /**
@@ -1 +1 @@
1
- import{parseMediaLines as t}from"../utils/media-response.js";import{createLogger as e}from"../utils/logger.js";const s=e("ChannelManager");export class ChannelManager{config;tokenDb;onMessage;adapters=new Map;constructor(t,e,s){this.config=t,this.tokenDb=e,this.onMessage=s}registerAdapter(t){this.adapters.set(t.name,t)}async startAll(){const t=[];for(const[e,n]of this.adapters)s.info(`Starting channel: ${e}`),t.push(n.start(this.onMessage).then(()=>{s.info(`Channel started: ${e}`)}).catch(t=>{s.error(`Failed to start channel ${e}: ${t}`)}));await Promise.allSettled(t)}async sendToChannel(t,e,n){const a=this.adapters.get(t);a?await a.sendText(e,n):s.error(`Channel not found: ${t}`)}async sendAudio(t,e,n,a){const r=this.adapters.get(t);r?r.sendAudio?await r.sendAudio(e,n,a):s.warn(`Channel ${t} does not support audio, skipping`):s.error(`Channel not found: ${t}`)}async sendButtons(t,e,n,a){const r=this.adapters.get(t);if(r)if(r.sendButtons)await r.sendButtons(e,n,a);else{const t=a.map((t,e)=>`${e+1}. ${t.text}`).join("\n");await r.sendText(e,`${n}\n\n${t}\n\nReply with your choice or type your answer.`)}else s.error(`Channel not found: ${t}`)}async setTyping(t,e){const s=this.adapters.get(t);if(s&&s.setTyping)try{await s.setTyping(e)}catch{}}async clearTyping(t,e){const s=this.adapters.get(t);if(s&&s.clearTyping)try{await s.clearTyping(e)}catch{}}async releaseTyping(t,e){const s=this.adapters.get(t);if(s)if(s.releaseTyping)try{await s.releaseTyping(e)}catch{}else if(s.clearTyping)try{await s.clearTyping(e)}catch{}}async sendResponse(e,n,a){const{textParts:r,mediaEntries:o}=t(a);for(const t of o)try{await this.sendAudio(e,n,t.path,t.asVoice)}catch(t){s.error(`Failed to send audio to ${e}:${n}: ${t}`)}const i=r.join("\n").trim();i&&await this.sendToChannel(e,n,i)}getAdapter(t){return this.adapters.get(t)}getChannel(t){return this.adapters.get(t)}async sendSystemMessage(t,e,n){const a=this.adapters.get(t);if(a)try{await a.sendText(e,n),s.debug(`System message sent to ${t}:${e}`)}catch(n){s.error(`Failed to send system message to ${t}:${e}: ${n}`)}else s.warn(`Cannot send system message: channel ${t} not found`)}listAdapters(){return[...this.adapters.entries()].map(([t])=>({name:t,active:!0}))}async stopAll(){const t=[];for(const[e,n]of this.adapters)s.info(`Stopping channel: ${e}`),t.push(n.stop().catch(t=>{s.error(`Error stopping channel ${e}: ${t}`)}));await Promise.allSettled(t)}}
1
+ import{parseMediaLines as t}from"../utils/media-response.js";import{createLogger as e}from"../utils/logger.js";const s=e("ChannelManager");export class ChannelManager{config;tokenDb;onMessage;adapters=new Map;constructor(t,e,s){this.config=t,this.tokenDb=e,this.onMessage=s}registerAdapter(t){this.adapters.set(t.name,t)}async startAll(){const t=[];for(const[e,a]of this.adapters)s.info(`Starting channel: ${e}`),t.push(a.start(this.onMessage).then(()=>{s.info(`Channel started: ${e}`)}).catch(t=>{s.error(`Failed to start channel ${e}: ${t}`)}));await Promise.allSettled(t)}async sendToChannel(t,e,a){const n=this.adapters.get(t);n?await n.sendText(e,a):s.error(`Channel not found: ${t}`)}async sendAudio(t,e,a,n){const r=this.adapters.get(t);r?r.sendAudio?await r.sendAudio(e,a,n):s.warn(`Channel ${t} does not support audio, skipping`):s.error(`Channel not found: ${t}`)}async sendButtons(t,e,a,n){const r=this.adapters.get(t);if(r)if(r.sendButtons)await r.sendButtons(e,a,n);else{const t=n.flat().map((t,e)=>`${e+1}. ${t.text}`).join("\n");await r.sendText(e,`${a}\n\n${t}\n\nReply with your choice or type your answer.`)}else s.error(`Channel not found: ${t}`)}async setTyping(t,e){const s=this.adapters.get(t);if(s&&s.setTyping)try{await s.setTyping(e)}catch{}}async clearTyping(t,e){const s=this.adapters.get(t);if(s&&s.clearTyping)try{await s.clearTyping(e)}catch{}}async releaseTyping(t,e){const s=this.adapters.get(t);if(s)if(s.releaseTyping)try{await s.releaseTyping(e)}catch{}else if(s.clearTyping)try{await s.clearTyping(e)}catch{}}async sendResponse(e,a,n){const{textParts:r,mediaEntries:o}=t(n);for(const t of o)try{await this.sendAudio(e,a,t.path,t.asVoice)}catch(t){s.error(`Failed to send audio to ${e}:${a}: ${t}`)}const i=r.join("\n").trim();i&&await this.sendToChannel(e,a,i)}getAdapter(t){return this.adapters.get(t)}getChannel(t){return this.adapters.get(t)}async sendSystemMessage(t,e,a){const n=this.adapters.get(t);if(n)try{await n.sendText(e,a),s.debug(`System message sent to ${t}:${e}`)}catch(a){s.error(`Failed to send system message to ${t}:${e}: ${a}`)}else s.warn(`Cannot send system message: channel ${t} not found`)}listAdapters(){return[...this.adapters.entries()].map(([t])=>({name:t,active:!0}))}async stopAll(){const t=[];for(const[e,a]of this.adapters)s.info(`Stopping channel: ${e}`),t.push(a.stop().catch(t=>{s.error(`Error stopping channel ${e}: ${t}`)}));await Promise.allSettled(t)}}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Telegram configuration types and schemas
3
+ *
4
+ * This module re-exports types from the main config and defines
5
+ * additional types used by the modular Telegram implementation.
6
+ */
7
+ import type { TelegramAccountConfig, TelegramActionConfig, TelegramRetryConfig, TelegramInlineButtonsScope } from "../../../config.js";
8
+ export type { TelegramAccountConfig, TelegramActionConfig, TelegramRetryConfig, TelegramInlineButtonsScope, };
9
+ /** Button style for Telegram inline keyboards */
10
+ export type TelegramButtonStyle = "danger" | "success" | "primary";
11
+ /** Inline button definition */
12
+ export interface TelegramInlineButton {
13
+ text: string;
14
+ callback_data: string;
15
+ style?: TelegramButtonStyle;
16
+ }
17
+ /** Inline buttons - array of button rows */
18
+ export type TelegramInlineButtons = TelegramInlineButton[][];
19
+ /** Chat type detection result */
20
+ export type TelegramChatType = "direct" | "group" | "unknown";
21
+ /** Result from send operations */
22
+ export interface TelegramSendResult {
23
+ messageId: string;
24
+ chatId: string;
25
+ }
26
+ /** Result from poll send operations */
27
+ export interface TelegramPollSendResult extends TelegramSendResult {
28
+ pollId?: string;
29
+ }
30
+ /** Options for sending messages */
31
+ export interface TelegramSendOptions {
32
+ token?: string;
33
+ accountId?: string;
34
+ mediaUrl?: string;
35
+ buttons?: TelegramInlineButtons;
36
+ replyToMessageId?: number;
37
+ messageThreadId?: number;
38
+ quoteText?: string;
39
+ asVoice?: boolean;
40
+ silent?: boolean;
41
+ retry?: TelegramRetryConfig;
42
+ linkPreview?: boolean;
43
+ textChunkLimit?: number;
44
+ }
45
+ /** Options for sending polls */
46
+ export interface TelegramPollOptions {
47
+ question: string;
48
+ options: string[];
49
+ maxSelections?: number;
50
+ durationSeconds?: number;
51
+ isAnonymous?: boolean;
52
+ replyToMessageId?: number;
53
+ messageThreadId?: number;
54
+ }
55
+ /** Options for reaction operations */
56
+ export interface TelegramReactOptions {
57
+ remove?: boolean;
58
+ retry?: TelegramRetryConfig;
59
+ }
60
+ /** Result from reaction operations */
61
+ export interface TelegramReactResult {
62
+ ok: boolean;
63
+ warning?: string;
64
+ }
65
+ /** Options for edit operations */
66
+ export interface TelegramEditOptions {
67
+ buttons?: TelegramInlineButtons;
68
+ linkPreview?: boolean;
69
+ }
70
+ /** Result from edit operations */
71
+ export interface TelegramEditResult {
72
+ ok: boolean;
73
+ messageId: string;
74
+ chatId: string;
75
+ }
76
+ /** Result from delete operations */
77
+ export interface TelegramDeleteResult {
78
+ ok: boolean;
79
+ }
80
+ /** Cached sticker metadata */
81
+ export interface CachedSticker {
82
+ fileId: string;
83
+ fileUniqueId: string;
84
+ emoji?: string;
85
+ setName?: string;
86
+ description: string;
87
+ }
88
+ /** Reaction level resolution result */
89
+ export interface ReactionLevelResult {
90
+ level: "off" | "ack" | "minimal" | "extensive";
91
+ agentReactionsEnabled: boolean;
92
+ }
93
+ //# sourceMappingURL=config-types.d.ts.map
@@ -0,0 +1 @@
1
+ export{};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Telegram edit and delete module
3
+ *
4
+ * Provides message editing and deletion functionality.
5
+ */
6
+ import type { TelegramEditOptions, TelegramEditResult, TelegramDeleteResult, TelegramRetryConfig } from "./config-types.js";
7
+ /**
8
+ * Edit a Telegram message.
9
+ *
10
+ * Features:
11
+ * - HTML parse fallback to plain text
12
+ * - Button removal support (pass empty array)
13
+ * - MESSAGE_NOT_MODIFIED error suppression (treated as success)
14
+ * - Retry policy integration
15
+ *
16
+ * @param chatId Chat ID where the message is located
17
+ * @param messageId Message ID to edit
18
+ * @param text New message text (markdown format)
19
+ * @param token Bot token
20
+ * @param opts Edit options (buttons, linkPreview, retry)
21
+ * @returns Edit result with message ID and chat ID
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // Edit text
26
+ * await editMessageTelegram(
27
+ * "123456",
28
+ * 42,
29
+ * "Updated text",
30
+ * "bot-token",
31
+ * );
32
+ *
33
+ * // Edit with buttons
34
+ * await editMessageTelegram(
35
+ * "123456",
36
+ * 42,
37
+ * "Choose:",
38
+ * "bot-token",
39
+ * {
40
+ * buttons: [[
41
+ * { text: "Yes", callback_data: "yes" },
42
+ * { text: "No", callback_data: "no" },
43
+ * ]],
44
+ * },
45
+ * );
46
+ *
47
+ * // Remove buttons
48
+ * await editMessageTelegram(
49
+ * "123456",
50
+ * 42,
51
+ * "Text without buttons",
52
+ * "bot-token",
53
+ * { buttons: [] },
54
+ * );
55
+ * ```
56
+ */
57
+ export declare function editMessageTelegram(chatId: string | number, messageId: string | number, text: string, token: string, opts?: TelegramEditOptions): Promise<TelegramEditResult>;
58
+ /**
59
+ * Delete a Telegram message.
60
+ *
61
+ * @param chatId Chat ID where the message is located
62
+ * @param messageId Message ID to delete
63
+ * @param token Bot token
64
+ * @param retry Optional retry configuration
65
+ * @returns Delete result
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * await deleteMessageTelegram("123456", 42, "bot-token");
70
+ * ```
71
+ */
72
+ export declare function deleteMessageTelegram(chatId: string | number, messageId: string | number, token: string, retry?: TelegramRetryConfig): Promise<TelegramDeleteResult>;
73
+ //# sourceMappingURL=edit-delete.d.ts.map
@@ -0,0 +1 @@
1
+ import{Bot as e}from"grammy";import{createLogger as t}from"../../../utils/logger.js";import{normalizeChatId as r,normalizeMessageId as i}from"./utils.js";import{buildInlineKeyboard as s}from"./inline-buttons.js";import{createRetryRunner as o}from"./retry-policy.js";import{withHtmlParseFallback as a,isTelegramMessageNotModifiedError as n}from"./error-handling.js";const d=t("telegram:edit");export async function editMessageTelegram(t,g,l,p,c={}){if(!p)throw new Error("Telegram bot token is required");const m=r(String(t)),u=i(g),b=new e(p).api,_=o(void 0,!1),f=function(e){let t=e;return t=t.replace(/```([\s\S]*?)```/g,"<pre><code>$1</code></pre>"),t=t.replace(/`([^`]+)`/g,"<code>$1</code>"),t=t.replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>"),t=t.replace(/\*([^*]+)\*/g,"<i>$1</i>"),t=t.replace(/__([^_]+)__/g,"<u>$1</u>"),t=t.replace(/~~([^~]+)~~/g,"<s>$1</s>"),t}(l),w=void 0!==c.buttons,k=w?s(c.buttons):void 0,M=w?k??{inline_keyboard:[]}:void 0,$={parse_mode:"HTML"};!1===c.linkPreview&&($.link_preview_options={is_disabled:!0}),void 0!==M&&($.reply_markup=M);const h={};!1===c.linkPreview&&(h.link_preview_options={is_disabled:!0}),void 0!==M&&(h.reply_markup=M);try{await a({label:"editMessage",requestHtml:()=>_(()=>b.editMessageText(m,u,f,$),"editMessage"),requestPlain:()=>_(()=>Object.keys(h).length>0?b.editMessageText(m,u,l,h):b.editMessageText(m,u,l),"editMessage-plain")})}catch(e){if(!n(e))throw e;d.debug(`Message ${u} not modified (content unchanged)`)}return d.debug(`Edited message ${u} in chat ${m}`),{ok:!0,messageId:String(u),chatId:m}}export async function deleteMessageTelegram(t,s,a,n){if(!a)throw new Error("Telegram bot token is required");const g=r(String(t)),l=i(s),p=new e(a).api,c=o(n,!1);return await c(()=>p.deleteMessage(g,l),"deleteMessage"),d.debug(`Deleted message ${l} from chat ${g}`),{ok:!0}}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Telegram error handling utilities
3
+ *
4
+ * This module provides error handling patterns for Telegram API calls:
5
+ * - HTML parse error fallback (retry as plain text)
6
+ * - Thread not found fallback (retry without thread ID)
7
+ * - Chat not found error enrichment
8
+ */
9
+ /**
10
+ * Check if error is a Telegram message not modified error.
11
+ */
12
+ export declare function isTelegramMessageNotModifiedError(err: unknown): boolean;
13
+ /**
14
+ * Wrap a Telegram API call with HTML parse error fallback.
15
+ *
16
+ * If the request fails with an HTML parse error, it retries with plain text.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const result = await withHtmlParseFallback({
21
+ * label: "sendMessage",
22
+ * requestHtml: () => api.sendMessage(chatId, text, { parse_mode: "HTML" }),
23
+ * requestPlain: () => api.sendMessage(chatId, text),
24
+ * });
25
+ * ```
26
+ */
27
+ export declare function withHtmlParseFallback<T>(params: {
28
+ label: string;
29
+ requestHtml: () => Promise<T>;
30
+ requestPlain: () => Promise<T>;
31
+ }): Promise<T>;
32
+ /**
33
+ * Wrap a Telegram API call with thread fallback.
34
+ *
35
+ * If the request fails with "message thread not found", it retries without the
36
+ * message_thread_id parameter.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const result = await withThreadFallback(
41
+ * { message_thread_id: 123, text: "Hello" },
42
+ * "sendMessage",
43
+ * async (effectiveParams) => api.sendMessage(chatId, effectiveParams),
44
+ * );
45
+ * ```
46
+ */
47
+ export declare function withThreadFallback<T>(params: Record<string, unknown> | undefined, label: string, attempt: (effectiveParams: Record<string, unknown> | undefined) => Promise<T>): Promise<T>;
48
+ /**
49
+ * Wrap a chat not found error with enhanced context.
50
+ *
51
+ * Enriches the error message with helpful troubleshooting information.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * try {
56
+ * await api.sendMessage(chatId, "Hello");
57
+ * } catch (err) {
58
+ * throw wrapChatNotFoundError(err, chatId, "message text");
59
+ * }
60
+ * ```
61
+ */
62
+ export declare function wrapChatNotFoundError(err: unknown, chatId: string, input: string): Error;
63
+ //# sourceMappingURL=error-handling.d.ts.map
@@ -0,0 +1 @@
1
+ import{createLogger as t}from"../../../utils/logger.js";const e=t("telegram:errors"),r=/can't parse entities|parse entities|find end of the entity/i,n=/400:\s*Bad Request:\s*message thread not found/i,i=/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i,a=/400: Bad Request: chat not found/i;function o(t){return t instanceof Error?t.message:String(t)}export function isTelegramMessageNotModifiedError(t){return i.test(o(t))}function s(t){return null!=t&&"message_thread_id"in t}export async function withHtmlParseFallback(t){try{return await t.requestHtml()}catch(n){if(!function(t){return r.test(o(t))}(n))throw n;return e.warn(`Telegram ${t.label} failed with HTML parse error, retrying as plain text: ${o(n)}`),await t.requestPlain()}}export async function withThreadFallback(t,r,i){try{return await i(t)}catch(a){if(!s(t)||!function(t){return n.test(o(t))}(a))throw a;e.warn(`Telegram ${r} failed with message_thread_id, retrying without thread: ${o(a)}`);const u=function(t){if(!t||!s(t))return t;const e={...t};return delete e.message_thread_id,Object.keys(e).length>0?e:void 0}(t);return await i(u)}}export function wrapChatNotFoundError(t,e,r){return function(t){return a.test(o(t))}(t)?new Error([`Telegram send failed: chat not found (chat_id=${e}).`,"Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.",`Input was: ${JSON.stringify(r)}.`].join(" ")):t instanceof Error?t:new Error(String(t))}
@@ -1,19 +1,28 @@
1
- import type { ChannelAdapter, MessageHandler, InlineButton } from "../bridge.js";
2
- import type { TokenDB } from "../../auth/token-db.js";
3
- interface TelegramAccountConfig {
4
- botToken: string;
5
- dmPolicy: string;
6
- allowFrom: Array<string | number>;
7
- }
1
+ /**
2
+ * Telegram Channel Adapter
3
+ *
4
+ * Modular Telegram channel implementation with advanced features:
5
+ * - Message reactions
6
+ * - Edit and delete messages
7
+ * - Sticker cache with fuzzy search
8
+ * - Forum topic & thread support
9
+ * - Inline button scope control
10
+ * - Advanced retry policy
11
+ * - Poll creation
12
+ */
13
+ import type { ChannelAdapter, MessageHandler, InlineButton } from "../../bridge.js";
14
+ import type { TokenDB } from "../../../auth/token-db.js";
15
+ import type { TelegramAccountConfig } from "../../../config.js";
8
16
  export declare class TelegramChannel implements ChannelAdapter {
9
17
  readonly name = "telegram";
10
18
  private bot;
11
19
  private config;
20
+ private accountId;
12
21
  private tokenDb;
13
22
  private typingIntervals;
14
23
  private inflightTyping;
15
24
  private inflightCount;
16
- constructor(config: TelegramAccountConfig, tokenDb: TokenDB, inflightTyping?: boolean);
25
+ constructor(config: TelegramAccountConfig, accountId: string, tokenDb: TokenDB, inflightTyping?: boolean);
17
26
  start(onMessage: MessageHandler): Promise<void>;
18
27
  sendText(chatId: string, text: string): Promise<void>;
19
28
  setTyping(chatId: string): Promise<void>;
@@ -23,8 +32,12 @@ export declare class TelegramChannel implements ChannelAdapter {
23
32
  releaseTyping(chatId: string): Promise<void>;
24
33
  private startTypingInterval;
25
34
  private stopTypingInterval;
26
- sendButtons(chatId: string, text: string, buttons: InlineButton[]): Promise<void>;
35
+ sendButtons(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
27
36
  sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
37
+ reactMessage(chatId: string, messageId: string, emoji: string, remove?: boolean): Promise<void>;
38
+ editMessage(chatId: string, messageId: string, text: string, buttons?: InlineButton[]): Promise<void>;
39
+ deleteMessage(chatId: string, messageId: string): Promise<void>;
40
+ sendSticker(chatId: string, fileId: string): Promise<void>;
28
41
  stop(): Promise<void>;
29
42
  /**
30
43
  * Process an incoming message in the background.
@@ -35,5 +48,4 @@ export declare class TelegramChannel implements ChannelAdapter {
35
48
  private downloadFile;
36
49
  private sendChunked;
37
50
  }
38
- export {};
39
- //# sourceMappingURL=telegram.d.ts.map
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ import{Bot as t,InputFile as e}from"grammy";import{validateChannelUser as i}from"../../../auth/auth-middleware.js";import{parseMediaLines as n}from"../../../utils/media-response.js";import{markdownToTelegramHtmlChunks as a}from"../../../utils/telegram-format.js";import{createLogger as o}from"../../../utils/logger.js";import{sendMessageTelegram as s}from"./send.js";import{reactMessageTelegram as r,resolveReactionLevel as c}from"./reactions.js";import{editMessageTelegram as l,deleteMessageTelegram as d}from"./edit-delete.js";import{sendStickerTelegram as h,cacheSticker as g}from"./stickers.js";import{validateButtonsForChatId as m}from"./inline-buttons.js";const f=o("Telegram");export class TelegramChannel{name="telegram";bot;config;accountId;tokenDb;typingIntervals=new Map;inflightTyping;inflightCount=new Map;constructor(e,i,n,a=!0){this.config=e,this.accountId=i,this.tokenDb=n,this.inflightTyping=a,this.bot=new t(e.botToken)}async start(t){this.bot.on("message",e=>{this.handleIncoming(e,t)}),this.bot.on("callback_query:data",e=>{const i=e.callbackQuery.data,n=String(e.from?.id??"unknown"),a=String(e.chat?.id??e.callbackQuery.message?.chat?.id??"unknown"),o=e.from?.username;e.answerCallbackQuery().catch(()=>{}),this.startTypingInterval(a);t({chatId:a,userId:n,channelName:"telegram",text:i,attachments:[],username:o,rawContext:e}).then(async t=>{t&&t.trim()&&await this.sendText(a,t)}).catch(t=>{f.error(`Error handling callback from ${n}: ${t}`)})}),f.info("Starting Telegram bot..."),this.bot.start({onStart:t=>{f.info(`Telegram bot started: @${t.username}`)}}).catch(t=>{String(t).includes("Aborted delay")?f.debug("Telegram polling stopped"):f.error(`Telegram bot polling error: ${t}`)})}async sendText(t,e){try{await s(t,e,{token:this.config.botToken,accountId:this.accountId,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}catch(t){throw f.error(`Failed to send message: ${t}`),t}await this.resendTypingIfActive(t)}async setTyping(t){this.typingIntervals.has(t)?await this.bot.api.sendChatAction(t,"typing").catch(()=>{}):this.startTypingInterval(t)}async resendTypingIfActive(t){this.typingIntervals.has(t)&&await this.bot.api.sendChatAction(t,"typing").catch(()=>{})}async clearTyping(t){this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async releaseTyping(t){this.stopTypingInterval(t)}startTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??0)+1;if(this.inflightCount.set(t,e),e>1)return}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.bot.api.sendChatAction(t,"typing").catch(()=>{});const i=setInterval(()=>{const e=this.inflightCount.get(t)??0;!this.inflightTyping||e>0?this.bot.api.sendChatAction(t,"typing").catch(()=>{}):(clearInterval(i),this.typingIntervals.delete(t))},4e3);this.typingIntervals.set(t,i)}stopTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??1)-1;return void(e>0?this.inflightCount.set(t,e):this.inflightCount.delete(t))}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async sendButtons(t,e,i){const n=i.map(t=>t.map(t=>({text:t.text,callback_data:t.callbackData??t.text})));try{m(n,this.config,t)}catch(i){return f.warn(`Button validation failed: ${i}, sending without buttons`),void await this.sendText(t,e)}await s(t,e,{token:this.config.botToken,accountId:this.accountId,buttons:n,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry}),await this.resendTypingIfActive(t)}async sendAudio(t,i,n){const a=new e(i);n?await this.bot.api.sendVoice(t,a):await this.bot.api.sendAudio(t,a),await this.resendTypingIfActive(t)}async reactMessage(t,e,i,n){const{level:a}=c(this.config);"off"!==a?await r(t,e,i,this.config.botToken,{remove:n}):f.debug("Reactions disabled for this account")}async editMessage(t,e,i,n){const a=n?.map(t=>({text:t.text,callback_data:t.callbackData??t.text}));await l(t,e,i,this.config.botToken,{buttons:a?[a]:void 0,linkPreview:this.config.linkPreview})}async deleteMessage(t,e){await d(t,e,this.config.botToken,this.config.retry)}async sendSticker(t,e){await h(t,e,this.config.botToken,{retry:this.config.retry}),await this.resendTypingIfActive(t)}async stop(){try{await this.bot.stop(),f.info("Telegram bot stopped"),await new Promise(t=>setTimeout(t,100))}catch(t){f.warn(`Error stopping Telegram bot: ${t}`)}}async handleIncoming(t,a){const o=String(t.from?.id??"unknown"),s=String(t.chat?.id??"unknown"),r=t.from?.username,c=i(this.tokenDb,o,"telegram",this.config.dmPolicy,this.config.allowFrom);if(!c.authorized)return f.warn(`Unauthorized message from ${o} (@${r})`),void await t.reply(c.reason??"Not authorized.");this.startTypingInterval(s);try{const i=await this.buildIncomingMessage(t,s,o,r),c=await a(i),{textParts:l,mediaEntries:d}=n(c);for(const i of d)try{const n=new e(i.path);i.asVoice?await t.replyWithVoice(n):await t.replyWithAudio(n)}catch(t){f.error(`Failed to send audio: ${t}`)}const h=l.join("\n").trim();h&&await this.sendChunked(t,h),await this.resendTypingIfActive(s)}catch(e){f.error(`Error handling message from ${o}: ${e}`),await t.reply("An error occurred while processing your message.").catch(()=>{})}}async buildIncomingMessage(t,e,i,n){const a=t.message,o=[];let s=a.text??a.caption??void 0;if(a.photo&&a.photo.length>0){const e=a.photo[a.photo.length-1];o.push({type:"image",mimeType:"image/jpeg",fileSize:e.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,e.file_id)})}if(a.voice&&o.push({type:"voice",mimeType:a.voice.mime_type??"audio/ogg",duration:a.voice.duration,fileSize:a.voice.file_size,getBuffer:()=>this.downloadFile(t,a.voice.file_id)}),a.audio&&o.push({type:"audio",mimeType:a.audio.mime_type??"audio/mpeg",fileName:a.audio.file_name,duration:a.audio.duration,fileSize:a.audio.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.audio.file_id)}),a.document&&o.push({type:"document",mimeType:a.document.mime_type,fileName:a.document.file_name,fileSize:a.document.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.document.file_id)}),a.video&&o.push({type:"video",mimeType:a.video.mime_type??"video/mp4",fileName:a.video.file_name,duration:a.video.duration,fileSize:a.video.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.video.file_id)}),a.video_note&&o.push({type:"video_note",mimeType:"video/mp4",duration:a.video_note.duration,fileSize:a.video_note.file_size,getBuffer:()=>this.downloadFile(t,a.video_note.file_id)}),a.sticker){if(this.config.actions?.sticker)try{g({fileId:a.sticker.file_id,fileUniqueId:a.sticker.file_unique_id,emoji:a.sticker.emoji,setName:a.sticker.set_name,description:a.sticker.emoji?`${a.sticker.emoji} sticker${a.sticker.set_name?` from ${a.sticker.set_name}`:""}`:a.sticker.set_name??"sticker"})}catch(t){f.warn(`Failed to cache sticker: ${t}`)}o.push({type:"sticker",mimeType:a.sticker.is_animated?"application/x-tgsticker":a.sticker.is_video?"video/webm":"image/webp",metadata:{emoji:a.sticker.emoji,setName:a.sticker.set_name},getBuffer:()=>this.downloadFile(t,a.sticker.file_id)})}return a.location&&o.push({type:"location",metadata:{latitude:a.location.latitude,longitude:a.location.longitude},getBuffer:async()=>Buffer.alloc(0)}),a.contact&&o.push({type:"contact",metadata:{phoneNumber:a.contact.phone_number,firstName:a.contact.first_name,lastName:a.contact.last_name,userId:a.contact.user_id},getBuffer:async()=>Buffer.alloc(0)}),{chatId:e,userId:i,channelName:"telegram",text:s,attachments:o,username:n,rawContext:t}}async downloadFile(t,e){const i=await t.api.getFile(e),n=`https://api.telegram.org/file/bot${this.config.botToken}/${i.file_path}`,a=await fetch(n);if(!a.ok)throw new Error(`Failed to download file: ${a.statusText}`);return Buffer.from(await a.arrayBuffer())}async sendChunked(t,e){const i=a(e,4096);for(const n of i)try{await t.reply(n,{parse_mode:"HTML"})}catch{const i=p(e,4096);for(const e of i)await t.reply(e);break}}}function p(t,e){if(t.length<=e)return[t];const i=[];let n=t;for(;n.length>0;){if(n.length<=e){i.push(n);break}let t=n.lastIndexOf("\n",e);t<=0&&(t=e),i.push(n.slice(0,t)),n=n.slice(t).trimStart()}return i}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Telegram inline buttons utilities
3
+ *
4
+ * This module provides inline button scope validation and keyboard rendering
5
+ * for Telegram's reply_markup system.
6
+ */
7
+ import type { InlineKeyboardMarkup } from "@grammyjs/types";
8
+ import type { TelegramAccountConfig, TelegramChatType, TelegramInlineButtonsScope, TelegramInlineButtons } from "./config-types.js";
9
+ /**
10
+ * Resolve inline buttons scope from account configuration.
11
+ *
12
+ * The scope determines where inline buttons are allowed:
13
+ * - "off": Buttons disabled everywhere
14
+ * - "dm": Buttons only in direct messages
15
+ * - "group": Buttons only in groups
16
+ * - "all": Buttons allowed everywhere
17
+ * - "allowlist": Buttons allowed based on allowlist (default)
18
+ */
19
+ export declare function resolveInlineButtonsScope(config: TelegramAccountConfig): TelegramInlineButtonsScope;
20
+ /**
21
+ * Validate that buttons are allowed for the given scope and chat type.
22
+ *
23
+ * Throws an error if buttons are not allowed in this context.
24
+ *
25
+ * @throws {Error} If buttons are used in an invalid context
26
+ */
27
+ export declare function validateButtonsForScope(buttons: TelegramInlineButtons | undefined, scope: TelegramInlineButtonsScope, chatType: TelegramChatType): void;
28
+ /**
29
+ * Build Telegram InlineKeyboardMarkup from button array.
30
+ *
31
+ * Takes an array of button rows (TelegramInlineButtons[][]).
32
+ *
33
+ * Filters out invalid buttons (missing text or callback_data).
34
+ * Returns undefined if no valid buttons remain.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * // Single row
39
+ * const markup = buildInlineKeyboard([
40
+ * [
41
+ * { text: "Yes", callback_data: "yes" },
42
+ * { text: "No", callback_data: "no" },
43
+ * ]
44
+ * ]);
45
+ *
46
+ * // Multiple rows
47
+ * const markup = buildInlineKeyboard([
48
+ * [{ text: "Option 1", callback_data: "opt1" }],
49
+ * [{ text: "Option 2", callback_data: "opt2" }],
50
+ * ]);
51
+ * ```
52
+ */
53
+ export declare function buildInlineKeyboard(buttons?: TelegramInlineButtons): InlineKeyboardMarkup | undefined;
54
+ /**
55
+ * Convenience wrapper to resolve chat type and validate buttons.
56
+ *
57
+ * @throws {Error} If buttons are not allowed in this context
58
+ */
59
+ export declare function validateButtonsForChatId(buttons: TelegramInlineButtons | undefined, config: TelegramAccountConfig, chatId: string): void;
60
+ //# sourceMappingURL=inline-buttons.d.ts.map
@@ -0,0 +1 @@
1
+ import{resolveChatType as t}from"./utils.js";export function resolveInlineButtonsScope(t){return t.inlineButtonsScope??"allowlist"}export function validateButtonsForScope(t,e,o){if(t&&0!==t.length){if("off"===e)throw new Error("Inline buttons are disabled for this Telegram account (scope: off)");if("dm"===e&&"direct"!==o)throw new Error(`Inline buttons are only allowed in direct messages for this account (scope: dm, chatType: ${o})`);if("group"===e&&"group"!==o)throw new Error(`Inline buttons are only allowed in groups for this account (scope: group, chatType: ${o})`)}}export function buildInlineKeyboard(t){if(!t||0===t.length)return;const e=t.map(t=>t.filter(t=>t?.text&&t?.callback_data).map(t=>{!function(t,e){if(t.length>64)throw new Error(`Telegram callback_data exceeds 64 characters (button: "${e}", length: ${t.length})`)}(t.callback_data,t.text);return{text:t.text,callback_data:t.callback_data}})).filter(t=>t.length>0);return 0!==e.length?{inline_keyboard:e}:void 0}export function validateButtonsForChatId(e,o,n){validateButtonsForScope(e,resolveInlineButtonsScope(o),t(n))}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Telegram polls module
3
+ *
4
+ * Provides poll creation functionality with validation.
5
+ */
6
+ import type { TelegramPollOptions, TelegramPollSendResult, TelegramRetryConfig } from "./config-types.js";
7
+ /**
8
+ * Send a poll to a Telegram chat.
9
+ *
10
+ * Features:
11
+ * - Poll validation (2-10 options, 5-600s duration)
12
+ * - Multi-select support
13
+ * - Thread support (forum topics, reply-to)
14
+ * - Anonymous/public polls
15
+ * - Retry policy integration
16
+ *
17
+ * @param to Target chat ID (with optional thread: "chatId:topic:topicId")
18
+ * @param poll Poll configuration (question, options, maxSelections, duration, anonymous)
19
+ * @param token Bot token
20
+ * @param retry Optional retry configuration
21
+ * @returns Poll send result with message ID, chat ID, and optional poll ID
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // Simple poll
26
+ * await sendPollTelegram(
27
+ * "123456",
28
+ * {
29
+ * question: "What's your favorite color?",
30
+ * options: ["Red", "Blue", "Green"],
31
+ * },
32
+ * "bot-token",
33
+ * );
34
+ *
35
+ * // Multi-select poll with duration
36
+ * await sendPollTelegram(
37
+ * "123456",
38
+ * {
39
+ * question: "Which features do you want?",
40
+ * options: ["Feature A", "Feature B", "Feature C"],
41
+ * maxSelections: 3,
42
+ * durationSeconds: 300,
43
+ * isAnonymous: false,
44
+ * },
45
+ * "bot-token",
46
+ * );
47
+ * ```
48
+ */
49
+ export declare function sendPollTelegram(to: string, poll: TelegramPollOptions, token: string, retry?: TelegramRetryConfig): Promise<TelegramPollSendResult>;
50
+ //# sourceMappingURL=polls.d.ts.map
@@ -0,0 +1 @@
1
+ import{Bot as o}from"grammy";import{createLogger as e}from"../../../utils/logger.js";import{parseTelegramTarget as t,normalizeChatId as r}from"./utils.js";import{buildThreadReplyParams as n}from"./thread-support.js";import{createRetryRunner as s}from"./retry-policy.js";import{withThreadFallback as i,wrapChatNotFoundError as l}from"./error-handling.js";const a=e("telegram:polls");export async function sendPollTelegram(e,d,m,h){if(!m)throw new Error("Telegram bot token is required");!function(o){if(!o.question||0===o.question.trim().length)throw new Error("Poll question cannot be empty");if(o.question.length>300)throw new Error("Poll question must be 300 characters or less");if(!Array.isArray(o.options)||o.options.length<2)throw new Error("Poll must have at least 2 options");if(o.options.length>10)throw new Error("Poll can have at most 10 options");for(const e of o.options){if(!e||0===e.trim().length)throw new Error("Poll option cannot be empty");if(e.length>100)throw new Error("Poll option must be 100 characters or less")}const e=o.maxSelections??1;if(e<1||e>o.options.length)throw new Error(`Poll maxSelections must be between 1 and ${o.options.length}`);if(void 0!==o.durationSeconds&&(o.durationSeconds<5||o.durationSeconds>600))throw new Error("Poll duration must be between 5 and 600 seconds")}(d);const p=t(e),c=r(p.chatId),u=new o(m).api,g=s(h,!1),w=n({targetMessageThreadId:p.messageThreadId,messageThreadId:d.messageThreadId,chatType:p.chatType,replyToMessageId:d.replyToMessageId}),f={allows_multiple_answers:(d.maxSelections??1)>1,is_anonymous:d.isAnonymous??!0,...void 0!==d.durationSeconds?{open_period:d.durationSeconds}:{},...w},y=async(o,t)=>{try{return await g(o,t)}catch(o){throw l(o,c,e)}};let P;P=Object.keys(w).length>0?await i(f,"sendPoll",o=>y(()=>u.sendPoll(c,d.question,d.options,o||{}),"sendPoll")):await y(()=>u.sendPoll(c,d.question,d.options,f),"sendPoll");const b=String(P.message_id),S=String(P.chat.id),I=P.poll?.id;return a.debug(`Sent poll to chat ${S}, message ${b}`),{messageId:b,chatId:S,pollId:I}}